二次开发源码提交

This commit is contained in:
2026-05-15 08:52:48 +08:00
parent 4131f7321a
commit eb35a1d067
191 changed files with 34566 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
/// API配置
class ApiConfig {
static const int connectTimeout = 5;
static const int receiveTimeout = 60;
static const int sendTimeout = 60;
/// API基础URL
static const String defaultBaseUrl = 'https://pan.gongyun.org/api/v4';
/// 获取API基础URL
static Future<String> get baseUrl async {
return defaultBaseUrl;
}
}
+26
View File
@@ -0,0 +1,26 @@
/// 应用配置
class AppConfig {
/// 应用名称
static const String appName = '公云存储';
/// 应用版本
static const String version = '1.0.0';
/// 构建号
static const int buildNumber = 1;
/// 是否为调试模式
static const bool debugMode = bool.fromEnvironment('dart.vm.product');
/// 默认分页大小
static const int defaultPageSize = 50;
/// 最大分页大小
static const int maxPageSize = 200;
/// 上传并发数
static const int uploadConcurrency = 3;
/// 下载并发数
static const int downloadConcurrency = 3;
}
+37
View File
@@ -0,0 +1,37 @@
/// 应用常量
class AppConstants {
// 存储键
static const String keyAccessToken = 'access_token';
static const String keyRefreshToken = 'refresh_token';
static const String keyAccessExpires = 'access_expires';
static const String keyRefreshExpires = 'refresh_expires';
static const String keyRememberMe = 'remember_me';
static const String keyThemeMode = 'theme_mode';
static const String keyLanguage = 'language';
// 文件类型
static const int fileTypeFile = 0;
static const int fileTypeFolder = 1;
// 默认图标
static const String defaultAvatar = 'assets/images/default_avatar.png';
// 时间格式
static const String dateFormat = 'yyyy-MM-dd';
static const String dateTimeFormat = 'yyyy-MM-dd HH:mm:ss';
static const String timeFormat = 'HH:mm:ss';
}
/// API响应码
class ApiCode {
static const int success = 0;
static const int continueCode = 203;
static const int credentialInvalid = 40020;
static const int incorrectPassword = 40069;
static const int lockConflict = 40073;
static const int staleVersion = 40076;
static const int credentialRequired = 401;
static const int permissionDenied = 403;
static const int notFound = 404;
static const int internalError = 500;
}
@@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class QuickAccessConfig {
final String id;
final String label;
final IconData icon;
final String path;
final Color color;
final bool isDefault;
const QuickAccessConfig({
required this.id,
required this.label,
required this.icon,
required this.path,
required this.color,
this.isDefault = false,
});
QuickAccessConfig copyWith({String? label, String? path, IconData? icon, Color? color}) =>
QuickAccessConfig(
id: id,
label: label ?? this.label,
icon: icon ?? this.icon,
path: path ?? this.path,
color: color ?? this.color,
isDefault: isDefault,
);
static const storageKey = 'quick_access_shortcuts_v2';
static const defaults = [
QuickAccessConfig(id: 'img', label: '图片', icon: LucideIcons.image, path: '/Images', color: Color(0xFFF0ABFC), isDefault: true),
QuickAccessConfig(id: 'vid', label: '视频', icon: LucideIcons.video, path: '/Videos', color: Color(0xFFFCD34D), isDefault: true),
QuickAccessConfig(id: 'doc', label: '文档', icon: LucideIcons.fileText, path: '/Documents', color: Color(0xFF93C5FD), isDefault: true),
QuickAccessConfig(id: 'mus', label: '音乐', icon: LucideIcons.music, path: '/Music', color: Color(0xFF86EFAC), isDefault: true),
];
static const iconPool = <IconData>[
LucideIcons.image,
LucideIcons.video,
LucideIcons.fileText,
LucideIcons.music,
LucideIcons.folder,
LucideIcons.download,
LucideIcons.archive,
LucideIcons.code,
LucideIcons.bookOpen,
LucideIcons.camera,
LucideIcons.film,
LucideIcons.headphones,
];
static const colorPool = <Color>[
Color(0xFFF0ABFC),
Color(0xFFFCD34D),
Color(0xFF93C5FD),
Color(0xFF86EFAC),
Color(0xFFFCA5A5),
Color(0xFFFDBA74),
Color(0xFFC4B5FD),
Color(0xFF67E8F9),
];
Map<String, dynamic> toJson() => {
'id': id,
'label': label,
'iconCode': icon.codePoint,
'path': path,
'color': color.toARGB32(),
'isDefault': isDefault,
};
factory QuickAccessConfig.fromJson(Map<String, dynamic> json) {
final iconCode = json['iconCode'] as int? ?? LucideIcons.folder.codePoint;
final matchedIcon = iconPool.cast<IconData?>().firstWhere(
(i) => i!.codePoint == iconCode,
orElse: () => LucideIcons.folder,
)!;
return QuickAccessConfig(
id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
label: json['label'] as String,
icon: matchedIcon,
path: json['path'] as String,
color: Color(json['color'] as int? ?? 0xFF93C5FD),
isDefault: json['isDefault'] as bool? ?? false,
);
}
static List<QuickAccessConfig> parseSaved(String saved) {
try {
final list = jsonDecode(saved) as List<dynamic>;
return list.map((e) => QuickAccessConfig.fromJson(e as Map<String, dynamic>)).toList();
} catch (_) {
return List.from(defaults);
}
}
static String serialize(List<QuickAccessConfig> items) {
return jsonEncode(items.map((e) => e.toJson()).toList());
}
/// 迁移旧格式(分号分隔的路径列表)
static List<QuickAccessConfig> migrateV1(String saved) {
final parts = saved.split(';');
final items = <QuickAccessConfig>[];
for (int i = 0; i < defaults.length; i++) {
if (i < parts.length && parts[i].isNotEmpty) {
items.add(defaults[i].copyWith(path: parts[i]));
} else {
items.add(defaults[i]);
}
}
return items;
}
}
extension ColorDarken on Color {
Color darken(double amount) {
final hsl = HSLColor.fromColor(this);
return hsl
.withLightness((hsl.lightness - amount).clamp(0.0, 1.0))
.toColor();
}
}
+30
View File
@@ -0,0 +1,30 @@
/// 存储键常量
class StorageKeys {
// 设置相关
static const String themeMode = 'theme_mode';
static const String customBaseUrl = 'custom_base_url';
static const String servers = 'flutter_servers';
static const String lastSelectedServer = 'last_selected_server_label';
// 上传相关
static const String uploadQueue = 'upload_queue';
static const String uploadTasks = 'upload_tasks';
// 下载相关
static const String downloadTasks = 'download_tasks';
static const String downloadWifiOnly = 'download_wifi_only';
static const String downloadRetries = 'download_retries';
// 任务记录
static const String taskRetentionDays = 'task_retention_days';
// 缓存相关
static const String cacheSettings = 'cache_settings';
// Gravatar 镜像
static const String gravatarMirrorEnabled = 'gravatar_mirror_enabled';
static const String gravatarMirrorUrl = 'gravatar_mirror_url';
// 搜索历史
static const String searchHistory = 'search_history';
}
+61
View File
@@ -0,0 +1,61 @@
/// 应用异常基类
class AppException implements Exception {
final String message;
final int? code;
final dynamic data;
AppException(this.message, {this.code, this.data});
@override
String toString() => 'AppException: $message (code: $code)';
}
/// 网络异常
class NetworkException extends AppException {
NetworkException(super.message, {super.code, super.data});
}
/// 认证异常
class AuthException extends AppException {
AuthException(super.message, {super.code, super.data});
}
/// 服务器异常
class ServerException extends AppException {
ServerException(super.message, {super.code, super.data});
}
/// Token过期异常
class TokenExpiredException extends AuthException {
TokenExpiredException()
: super('Token已过期,请重新登录', code: 401);
}
/// RefreshToken过期异常
class RefreshTokenExpiredException extends AuthException {
RefreshTokenExpiredException()
: super('登录已过期,请重新登录', code: 40020);
}
/// 未授权异常
class UnauthorizedException extends AuthException {
UnauthorizedException() : super('未授权,请先登录', code: 403);
}
/// 文件不存在异常
class FileNotFoundException extends AppException {
FileNotFoundException() : super('文件不存在', code: 404);
}
/// 存储空间不足异常
class StorageSpaceException extends AppException {
StorageSpaceException() : super('存储空间不足', code: 507);
}
/// 两步验证需要异常
class TwoFactorRequiredException extends AuthException {
final String sessionId;
TwoFactorRequiredException(this.sessionId)
: super('需要两步验证', code: 203);
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:io';
import 'package:logger/logger.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// 应用日志类
class AppLogger {
AppLogger._();
static Logger? _logger;
static File? _logFile;
/// 初始化日志,必须在 main 中 await
static Future<void> init() async {
if (_logger != null) return;
// 1. 获取日志存储路径 (Windows: $HOME/AppData/Roaming/com.limo/cloudreve4_flutter/logs)
final appDir = await getApplicationSupportDirectory();
final logDir = Directory(p.join(appDir.path, 'logs'));
if (!await logDir.exists()) {
await logDir.create(recursive: true);
}
_logFile = File(p.join(logDir.path, 'log.txt'));
// 2. 配置多路输出:同时输出到控制台和文件
_logger = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.dateAndTime,
),
output: MultiOutput([
ConsoleOutput(),
CustomFileOutput(
file: _logFile!,
),
]),
filter: ProductionFilter(),
);
}
// 使用 getter 确保 logger 已初始化,防止空指针
static Logger get _instance {
_logger ??= Logger(
printer: PrettyPrinter(
methodCount: 0,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
),
);
return _logger!;
}
/// Debug 级别日志
static void d(String message) => _instance.d(message);
/// Info 级别日志
static void i(String message) => _instance.i(message);
/// Warning 级别日志
static void w(String message) => _instance.w(message);
/// Error 级别日志
static void e(String message) => _instance.e(message);
/// Debug 级别日志(支持格式化)
static void df(String message, List<Object> args) => _instance.d(message, error: args);
/// Info 级别日志(支持格式化)
static void ifn(String message, List<Object> args) => _instance.i(message, error: args);
/// Warning 级别日志(支持格式化)
static void wf(String message, List<Object> args) => _instance.w(message, error: args);
/// Error 级别日志(支持格式化)
static void ef(String message, List<Object> args) => _instance.e(message, error: args);
/// 获取日志文件路径
static Future<String> get logFilePath async {
if (_logFile != null) return _logFile!.path;
final appDir = await getApplicationSupportDirectory();
return p.join(appDir.path, 'logs', 'log.txt');
}
/// 获取日志文件大小(字节)
static Future<int> get logFileSize async {
final path = await logFilePath;
final file = File(path);
if (await file.exists()) {
return await file.length();
}
return 0;
}
/// 清空日志文件(内容清零,不删除文件)
static Future<void> clearLog() async {
final path = await logFilePath;
final file = File(path);
if (await file.exists()) {
await file.writeAsString('');
}
}
/// 导出日志文件到指定目录
static Future<String?> exportLog(String targetDir) async {
final srcPath = await logFilePath;
final srcFile = File(srcPath);
if (!await srcFile.exists()) return null;
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19);
final destPath = p.join(targetDir, 'cloudreve_log_$timestamp.txt');
await srcFile.copy(destPath);
return destPath;
}
/// 读取日志内容(用于预览)
static Future<String> readLog({int maxLines = 500}) async {
final path = await logFilePath;
final file = File(path);
if (!await file.exists()) return '';
final lines = await file.readAsLines();
if (lines.length <= maxLines) {
return lines.join('\n');
}
return '... (仅显示最近 $maxLines 行)\n\n'
'${lines.sublist(lines.length - maxLines).join('\n')}';
}
}
/// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加
class CustomFileOutput extends LogOutput {
final File file;
CustomFileOutput({required this.file});
@override
void output(OutputEvent event) {
for (var line in event.lines) {
// 过滤掉 ANSI 颜色代码,防止 log.txt 乱码
final cleanLine = line.replaceAll(RegExp(r'\x1B\[[0-9;]*[a-zA-Z]'), '');
file.writeAsStringSync('$cleanLine\n', mode: FileMode.writeOnlyAppend);
}
}
}
/// 日志帮助类
/// 提供一个全局的静态日志实例
class Log {
static void d(String message) => AppLogger.d(message);
static void i(String message) => AppLogger.i(message);
static void w(String message) => AppLogger.w(message);
static void e(String message) => AppLogger.e(message);
static void df(String message, List<Object> args) => AppLogger.df(message, args);
static void ifn(String message, List<Object> args) => AppLogger.ifn(message, args);
static void wf(String message, List<Object> args) => AppLogger.wf(message, args);
static void ef(String message, List<Object> args) => AppLogger.ef(message, args);
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:cloudreve4_flutter/core/constants/storage_keys.dart';
/// 头像相关工具
class AvatarUtils {
AvatarUtils._();
/// 获取 Gravatar URL(异步,因为需要读取镜像配置)
static Future<String> getGravatarUrl(String email, {int size = 200}) async {
final cleanEmail = email.trim().toLowerCase();
final hash = md5.convert(utf8.encode(cleanEmail)).toString();
final mirror = await getGravatarMirror();
final base = mirror ?? 'https://www.gravatar.com';
return '$base/avatar/$hash?s=$size&d=identicon';
}
/// 获取服务器头像 URL
static String getServerAvatarUrl(String baseUrl, String userId) {
return '$baseUrl/user/avatar/$userId';
}
/// 获取 Gravatar 镜像配置
static Future<String?> getGravatarMirror() async {
final enabled = await StorageService.instance
.getBool(StorageKeys.gravatarMirrorEnabled) ??
true;
if (!enabled) return null;
return await StorageService.instance
.getString(StorageKeys.gravatarMirrorUrl) ??
'https://weavatar.com';
}
/// 设置 Gravatar 镜像启用状态
static Future<void> setGravatarMirrorEnabled(bool enabled) async {
await StorageService.instance
.setBool(StorageKeys.gravatarMirrorEnabled, enabled);
}
/// 设置 Gravatar 镜像地址
static Future<void> setGravatarMirrorUrl(String? url) async {
await StorageService.instance
.setString(StorageKeys.gravatarMirrorUrl, url);
}
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:intl/intl.dart';
/// 日期工具类
class DateUtils {
/// 格式化日期
static String formatDate(DateTime? date, {String pattern = 'yyyy-MM-dd'}) {
if (date == null) return '-';
final localDate = date.isUtc ? date.toLocal() : date;
return DateFormat(pattern).format(localDate);
}
/// 格式化日期时间
static String formatDateTime(DateTime? dateTime) {
return formatDate(dateTime, pattern: 'yyyy-MM-dd HH:mm:ss');
}
/// 格式化时间
static String formatTime(DateTime? dateTime) {
return formatDate(dateTime, pattern: 'HH:mm:ss');
}
/// 格式化文件大小
static String formatFileSize(int? bytes) {
if (bytes == null || bytes < 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
double size = bytes.toDouble();
int unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return '${size.toStringAsFixed(2)} ${units[unitIndex]}';
}
/// 获取相对时间描述
static String getRelativeTime(DateTime? dateTime) {
if (dateTime == null) return '-';
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '${difference.inSeconds}秒前';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else if (difference.inDays < 30) {
return '${difference.inDays}天前';
} else if (difference.inDays < 365) {
return '${(difference.inDays / 30).floor()}个月前';
} else {
return '${(difference.inDays / 365).floor()}年前';
}
}
/// 判断是否为今天
static bool isToday(DateTime? dateTime) {
if (dateTime == null) return false;
final now = DateTime.now();
return dateTime.year == now.year &&
dateTime.month == now.month &&
dateTime.day == now.day;
}
/// 判断是否为昨天
static bool isYesterday(DateTime? dateTime) {
if (dateTime == null) return false;
final now = DateTime.now();
final yesterday = now.subtract(const Duration(days: 1));
return dateTime.year == yesterday.year &&
dateTime.month == yesterday.month &&
dateTime.day == yesterday.day;
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/file_model.dart';
import 'file_utils.dart';
/// 文件图标、颜色、类型标签统一管理
class FileIconUtils {
// ---- 文件图标 ----
// 后缀分类映射:安装包/可执行/字体/数据库/镜像等
static const _installerExtensions = {'apk', 'exe', 'msi', 'dmg', 'deb', 'rpm', 'appimage', 'snap'};
static const _executableExtensions = {'sh', 'bat', 'cmd', 'ps1', 'bin', 'run'};
static const _fontExtensions = {'ttf', 'otf', 'woff', 'woff2', 'eot'};
static const _databaseExtensions = {'db', 'sqlite', 'sqlite3', 'mdb', 'sql'};
static const _imageDiskExtensions = {'iso', 'img', 'vmdk', 'vdi', 'qcow2'};
static const _torrentExtensions = {'torrent'};
static IconData getFileIcon(String fileName) {
if (FileUtils.isImageFile(fileName)) return LucideIcons.image;
if (FileUtils.isVideoFile(fileName)) return LucideIcons.video;
if (FileUtils.isAudioFile(fileName)) return LucideIcons.music;
if (FileUtils.isPdfFile(fileName)) return LucideIcons.fileText;
if (FileUtils.isTextFile(fileName)) return LucideIcons.fileText;
if (FileUtils.isCodeFile(fileName)) return LucideIcons.code;
if (FileUtils.isArchiveFile(fileName)) return LucideIcons.archive;
if (FileUtils.isDocumentFile(fileName)) return LucideIcons.file;
// fallback: 按后缀分类
final ext = FileUtils.getFileExtension(fileName);
if (_installerExtensions.contains(ext)) return LucideIcons.package;
if (_executableExtensions.contains(ext)) return LucideIcons.terminal;
if (_fontExtensions.contains(ext)) return LucideIcons.type;
if (_databaseExtensions.contains(ext)) return LucideIcons.database;
if (_imageDiskExtensions.contains(ext)) return LucideIcons.hardDrive;
if (_torrentExtensions.contains(ext)) return LucideIcons.download;
return LucideIcons.file;
}
static Color getFileIconColor(String fileName) {
if (FileUtils.isImageFile(fileName)) return const Color(0xFFA855F7);
if (FileUtils.isVideoFile(fileName)) return const Color(0xFFF97316);
if (FileUtils.isAudioFile(fileName)) return const Color(0xFF3B82F6);
if (FileUtils.isPdfFile(fileName)) return const Color(0xFFEF4444);
if (FileUtils.isTextFile(fileName)) return const Color(0xFF14B8A6);
if (FileUtils.isCodeFile(fileName)) return const Color(0xFF06B6D4);
if (FileUtils.isArchiveFile(fileName)) return const Color(0xFFF59E0B);
if (FileUtils.isDocumentFile(fileName)) return const Color(0xFF6366F1);
// fallback: 按后缀分类
final ext = FileUtils.getFileExtension(fileName);
if (_installerExtensions.contains(ext)) return const Color(0xFF22C55E);
if (_executableExtensions.contains(ext)) return const Color(0xFF10B981);
if (_fontExtensions.contains(ext)) return const Color(0xFF8B5CF6);
if (_databaseExtensions.contains(ext)) return const Color(0xFF6366F1);
if (_imageDiskExtensions.contains(ext)) return const Color(0xFF64748B);
if (_torrentExtensions.contains(ext)) return const Color(0xFF06B6D4);
// 真正未知:基于后缀名 hash 生成稳定颜色
if (ext.isNotEmpty) return _stableColor(ext);
return const Color(0xFF64748B);
}
/// 基于字符串生成稳定的颜色(避免同类型不同后缀撞色)
static Color _stableColor(String key) {
final hash = key.hashCode.abs();
const palette = [
Color(0xFF64748B), Color(0xFF6366F1), Color(0xFF8B5CF6),
Color(0xFFEC4899), Color(0xFF14B8A6), Color(0xFFF59E0B),
Color(0xFF22C55E), Color(0xFF0EA5E9), Color(0xFFEF4444),
];
return palette[hash % palette.length];
}
/// 文件类型中文标签
static String getFileTypeLabel(String fileName, {bool isFolder = false}) {
if (isFolder) return '文件夹';
if (FileUtils.isImageFile(fileName)) return '图片';
if (FileUtils.isVideoFile(fileName)) return '视频';
if (FileUtils.isAudioFile(fileName)) return '音频';
if (FileUtils.isPdfFile(fileName)) return 'PDF';
if (FileUtils.isTextFile(fileName)) {
final ext = FileUtils.getFileExtension(fileName);
return ext.isNotEmpty ? '${ext.toUpperCase()}文件' : '文本';
}
if (FileUtils.isCodeFile(fileName)) {
final ext = FileUtils.getFileExtension(fileName);
return ext.isNotEmpty ? '${ext.toUpperCase()}文件' : '代码';
}
if (FileUtils.isArchiveFile(fileName)) return '压缩包';
if (FileUtils.isDocumentFile(fileName)) return '文档';
// fallback: 未知后缀直接用大写后缀名
final ext = FileUtils.getFileExtension(fileName);
if (ext.isNotEmpty) return '${ext.toUpperCase()}文件';
return '文件';
}
// ---- 语义化文件夹图标 ----
static final _folderIconMap = <String, IconData>{
'DOCUMENTS': LucideIcons.fileText,
'DOCS': LucideIcons.fileText,
'PICTURES': LucideIcons.image,
'PHOTOS': LucideIcons.image,
'IMAGES': LucideIcons.image,
'VIDEOS': LucideIcons.video,
'MOVIES': LucideIcons.video,
'MUSIC': LucideIcons.music,
'AUDIO': LucideIcons.music,
'DOWNLOADS': LucideIcons.download,
'GAMES': LucideIcons.gamepad2,
'OS_IMAGE': LucideIcons.hardDrive,
'DOCKER': LucideIcons.container,
'CONTAINERS': LucideIcons.container,
'ARIA2': LucideIcons.download,
'BACKUP': LucideIcons.archive,
'DESKTOP': LucideIcons.monitor,
'FAVORITES': LucideIcons.star,
'SHARE': LucideIcons.share2,
'SHARED': LucideIcons.share2,
'TRASH': LucideIcons.trash2,
'RECYCLE': LucideIcons.trash2,
};
static final _folderColorMap = <String, Color>{
'DOCUMENTS': const Color(0xFF3B82F6),
'DOCS': const Color(0xFF3B82F6),
'PICTURES': const Color(0xFFA855F7),
'PHOTOS': const Color(0xFFA855F7),
'IMAGES': const Color(0xFFA855F7),
'VIDEOS': const Color(0xFFF97316),
'MOVIES': const Color(0xFFF97316),
'MUSIC': const Color(0xFF3B82F6),
'AUDIO': const Color(0xFF3B82F6),
'DOWNLOADS': const Color(0xFF22C55E),
'GAMES': const Color(0xFF8B5CF6),
'OS_IMAGE': const Color(0xFF64748B),
'DOCKER': const Color(0xFF0EA5E9),
'CONTAINERS': const Color(0xFF0EA5E9),
'ARIA2': const Color(0xFF06B6D4),
'BACKUP': const Color(0xFFF59E0B),
'DESKTOP': const Color(0xFF6366F1),
'FAVORITES': const Color(0xFFEAB308),
'SHARE': const Color(0xFF14B8A6),
'SHARED': const Color(0xFF14B8A6),
'TRASH': const Color(0xFFEF4444),
'RECYCLE': const Color(0xFFEF4444),
};
static final _folderKeywords = <String, IconData>{
'文档': LucideIcons.fileText,
'图片': LucideIcons.image,
'相册': LucideIcons.image,
'视频': LucideIcons.video,
'音乐': LucideIcons.music,
'下载': LucideIcons.download,
'游戏': LucideIcons.gamepad2,
'备份': LucideIcons.archive,
'桌面': LucideIcons.monitor,
'收藏': LucideIcons.star,
'分享': LucideIcons.share2,
'回收站': LucideIcons.trash2,
};
static final _folderKeywordColors = <String, Color>{
'文档': const Color(0xFF3B82F6),
'图片': const Color(0xFFA855F7),
'相册': const Color(0xFFA855F7),
'视频': const Color(0xFFF97316),
'音乐': const Color(0xFF3B82F6),
'下载': const Color(0xFF22C55E),
'游戏': const Color(0xFF8B5CF6),
'备份': const Color(0xFFF59E0B),
'桌面': const Color(0xFF6366F1),
'收藏': const Color(0xFFEAB308),
'分享': const Color(0xFF14B8A6),
'回收站': const Color(0xFFEF4444),
};
static IconData getFolderIcon(String folderName) {
final upper = folderName.toUpperCase();
if (_folderIconMap.containsKey(upper)) return _folderIconMap[upper]!;
for (final entry in _folderKeywords.entries) {
if (folderName.contains(entry.key)) return entry.value;
}
return LucideIcons.folder;
}
static Color? getFolderIconColor(String folderName) {
final upper = folderName.toUpperCase();
if (_folderColorMap.containsKey(upper)) return _folderColorMap[upper]!;
for (final entry in _folderKeywordColors.entries) {
if (folderName.contains(entry.key)) return entry.value;
}
return null; // 使用 colorScheme.primary 作为默认
}
// ---- 统一图标容器组件 ----
static Widget buildIconWidget({
required BuildContext context,
required FileModel file,
double size = 36,
double iconSize = 20,
double borderRadius = 8,
}) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
if (file.isFolder) {
final folderColor = getFolderIconColor(file.name) ?? colorScheme.primary;
final folderIcon = getFolderIcon(file.name);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: folderColor.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(borderRadius),
),
child: Icon(folderIcon, color: folderColor, size: iconSize),
);
}
final icon = getFileIcon(file.name);
final iconColor = getFileIconColor(file.name);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(borderRadius),
),
child: Icon(icon, color: iconColor, size: iconSize),
);
}
}
+171
View File
@@ -0,0 +1,171 @@
/// 文件类型工具类
class FileTypeUtils {
/// 图片扩展名
static const _imageExtensions = {
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
'ico',
'tiff',
'tif',
};
/// PDF扩展名
static const _pdfExtensions = {'pdf'};
/// 视频扩展名
static const _videoExtensions = {
'mp4',
'avi',
'mov',
'wmv',
'flv',
'mkv',
'webm',
'm4v',
'3gp',
'3g2',
};
/// 音频扩展名
static const _audioExtensions = {
'mp3',
'wav',
'ogg',
'flac',
'aac',
'm4a',
'wma',
};
/// 文档扩展名
static const _documentExtensions = {
'txt',
'md',
'json',
'xml',
'yaml',
'yml',
'ini',
'cfg',
'conf',
'log',
'csv',
};
/// 代码文件扩展名
static const _codeExtensions = {
'dart',
'js',
'ts',
'html',
'css',
'scss',
'less',
'python',
'py',
'java',
'kt',
'kts',
'swift',
'cpp',
'c',
'h',
'hpp',
'cs',
'go',
'rs',
'php',
'rb',
'sql',
'sh',
'bash',
'zsh',
'ps1',
'bat',
'cmd',
};
/// 检测是否为图片
static bool isImage(String fileName) {
final ext = getExtension(fileName);
return _imageExtensions.contains(ext);
}
/// 检测是否为PDF
static bool isPdf(String fileName) {
final ext = getExtension(fileName);
return _pdfExtensions.contains(ext);
}
/// 检测是否为视频
static bool isVideo(String fileName) {
final ext = getExtension(fileName);
return _videoExtensions.contains(ext);
}
/// 检测是否为音频
static bool isAudio(String fileName) {
final ext = getExtension(fileName);
return _audioExtensions.contains(ext);
}
/// 检测是否为文档
static bool isDocument(String fileName) {
final ext = getExtension(fileName);
return _documentExtensions.contains(ext) || _codeExtensions.contains(ext);
}
/// 检测是否为Markdown
static bool isMarkdown(String fileName) {
final ext = getExtension(fileName);
return ext == 'md';
}
/// 检测是否为文本或代码文件(排除markdown)
static bool isTextCode(String fileName) {
final ext = getExtension(fileName);
return (_documentExtensions.contains(ext) && ext != 'md') ||
_codeExtensions.contains(ext);
}
/// 检测是否支持预览
static bool isPreviewable(String fileName) {
return isImage(fileName) ||
isPdf(fileName) ||
isVideo(fileName) ||
isAudio(fileName) ||
isDocument(fileName);
}
/// 获取文件扩展名
static String getExtension(String fileName) {
final parts = fileName.split('.');
if (parts.length < 2) {
return '';
}
return parts.last.toLowerCase();
}
/// 获取文件类型描述
static String getFileTypeDescription(String fileName) {
if (isImage(fileName)) {
return '图片';
} else if (isPdf(fileName)) {
return 'PDF文档';
} else if (isVideo(fileName)) {
return '视频';
} else if (isAudio(fileName)) {
return '音频';
} else if (isMarkdown(fileName)) {
return 'Markdown文档';
} else if (isDocument(fileName)) {
return '文档';
}
return '文件';
}
}
+128
View File
@@ -0,0 +1,128 @@
/// 文件工具类
class FileUtils {
/// 将路径转换为 Cloudreve URI 格式
/// "/" → "cloudreve://my", "/subfolder" → "cloudreve://my/subfolder"
static String toCloudreveUri(String path) {
if (path.startsWith('cloudreve://')) return path;
if (path == '/' || path.isEmpty) return 'cloudreve://my';
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return 'cloudreve://my/$cleanPath';
}
/// 获取文件扩展名
static String getFileExtension(String fileName) {
final dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) return '';
return fileName.substring(dotIndex + 1).toLowerCase();
}
/// 判断是否为图片文件
static bool isImageFile(String fileName) {
const imageExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic'
];
return imageExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为视频文件
static bool isVideoFile(String fileName) {
const videoExtensions = [
'mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'
];
return videoExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为音频文件
static bool isAudioFile(String fileName) {
const audioExtensions = [
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'
];
return audioExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为PDF文件
static bool isPdfFile(String fileName) {
return getFileExtension(fileName) == 'pdf';
}
/// 判断是否为文本文件
static bool isTextFile(String fileName) {
const textExtensions = [
'txt', 'md', 'json', 'xml', 'yaml', 'yml', 'ini', 'conf'
];
return textExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为代码文件
static bool isCodeFile(String fileName) {
const codeExtensions = [
'js', 'ts', 'tsx', 'jsx', 'dart', 'java', 'py', 'c', 'cpp',
'h', 'hpp', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt',
'html', 'css', 'scss', 'less', 'sql', 'sh', 'bat'
];
return codeExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为压缩文件
static bool isArchiveFile(String fileName) {
const archiveExtensions = [
'zip', 'rar', '7z', 'tar', 'gz', 'bz2'
];
return archiveExtensions.contains(getFileExtension(fileName));
}
/// 判断是否为文档文件
static bool isDocumentFile(String fileName) {
const docExtensions = [
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp'
];
return docExtensions.contains(getFileExtension(fileName));
}
/// 判断是否可预览
static bool isPreviewable(String fileName) {
return isImageFile(fileName) || isVideoFile(fileName) ||
isPdfFile(fileName) || isTextFile(fileName) || isCodeFile(fileName);
}
/// 获取MIME类型
static String getMimeType(String fileName) {
final ext = getFileExtension(fileName);
final mimeTypes = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'mp4': 'video/mp4',
'webm': 'video/webm',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'ogg': 'audio/ogg',
'pdf': 'application/pdf',
'txt': 'text/plain',
'json': 'application/json',
'xml': 'application/xml',
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
};
return mimeTypes[ext] ?? 'application/octet-stream';
}
/// 获取文件图标
static String getFileIcon(String fileName) {
if (isImageFile(fileName)) return 'assets/icons/image.svg';
if (isVideoFile(fileName)) return 'assets/icons/video.svg';
if (isAudioFile(fileName)) return 'assets/icons/audio.svg';
if (isPdfFile(fileName)) return 'assets/icons/pdf.svg';
if (isTextFile(fileName)) return 'assets/icons/text.svg';
if (isCodeFile(fileName)) return 'assets/icons/code.svg';
if (isArchiveFile(fileName)) return 'assets/icons/archive.svg';
if (isDocumentFile(fileName)) return 'assets/icons/document.svg';
return 'assets/icons/file.svg';
}
}
+221
View File
@@ -0,0 +1,221 @@
class LanguagePreview {
/// 语言名称映射
static final getExtNameMapping = {
'1c': '1C',
'abnf': 'ABNF',
'accesslog': 'Access Log',
'actionscript': 'ActionScript',
'ada': 'Ada',
'angelscript': 'AngelScript',
'apache': 'Apache',
'applescript': 'AppleScript',
'arcade': 'Arcade',
'arduino': 'Arduino',
'armasm': 'ARM Assembly',
'asciidoc': 'AsciiDoc',
'aspectj': 'AspectJ',
'autohotkey': 'AutoHotkey',
'autoit': 'AutoIt',
'avrasm': 'AVR Assembly',
'awk': 'AWK',
'axapta': 'Axapta',
'bash': 'Bash',
'basic': 'BASIC',
'bnf': 'BNF',
'brainfuck': 'Brainfuck',
'cal': 'Cal',
'capnproto': 'Cap\'n Proto',
'ceylon': 'Ceylon',
'clean': 'Clean',
'clojure-repl': 'Clojure REPL',
'clojure': 'Clojure',
'cmake': 'CMake',
'coffeescript': 'CoffeeScript',
'coq': 'Coq',
'cos': 'Cos',
'cpp': 'C++',
'crmsh': 'crmsh',
'crystal': 'Crystal',
'c': 'C',
'cs': 'C#',
'csp': 'CSP',
'css': 'CSS',
'd': 'D',
'dart': 'Dart',
'delphi': 'Delphi',
'diff': 'Diff',
'django': 'Django',
'dns': 'DNS',
'dockerfile': 'Dockerfile',
'dos': 'DOS',
'dsconfig': 'DSConfig',
'dts': 'TypeScript',
'dust': 'Dust',
'ebnf': 'EBNF',
'elixir': 'Elixir',
'elm': 'Elm',
'erb': 'ERB',
'erlang-repl': 'Erlang REPL',
'erlang': 'Erlang',
'excel': 'Excel',
'fix': 'Fix',
'flix': 'Flix',
'fortran': 'Fortran',
'fsharp': 'F#',
'gams': 'GAMS',
'gauss': 'GAUSS',
'gcode': 'G-code',
'gherkin': 'Gherkin',
'glsl': 'GLSL',
'gml': 'GML',
'go': 'Go',
'golo': 'Golo',
'gradle': 'Gradle',
'groovy': 'Groovy',
'haml': 'HAML',
'handlebars': 'Handlebars',
'haskell': 'Haskell',
'haxe': 'Haxe',
'hsp': 'HSP',
'htmlbars': 'HTMLBars',
'http': 'HTTP',
'hy': 'Hy',
'inform7': 'Inform 7',
'ini': 'INI',
'irpf90': 'IRPF90',
'isbl': 'ISBL',
'java': 'Java',
'javascript': 'JavaScript',
'jboss-cli': 'JBoss CLI',
'json': 'JSON',
'julia-repl': 'Julia REPL',
'julia': 'Julia',
'kotlin': 'Kotlin',
'lasso': 'Lasso',
'ldif': 'LDIF',
'leaf': 'Leaf',
'less': 'Less',
'lisp': 'Lisp',
'livecodeserver': 'LiveCode Server',
'livescript': 'LiveScript',
'llvm': 'LLVM IR',
'lsl': 'LSL',
'lua': 'Lua',
'makefile': 'Makefile',
'markdown': 'Markdown',
'mathematica': 'Mathematica',
'matlab': 'MATLAB',
'maxima': 'Maxima',
'mel': 'MEL',
'mercury': 'Mercury',
'mipsasm': 'MIPS Assembly',
'mizar': 'Mizar',
'mojolicious': 'Mojolicious',
'monkey': 'Monkey',
'moonscript': 'MoonScript',
'n1ql': 'N1QL',
'nginx': 'Nginx',
'nimrod': 'Nimrod',
'nix': 'Nix',
'nsis': 'NSIS',
'objectivec': 'Objective-C',
'ocaml': 'OCaml',
'openscad': 'OpenSCAD',
'oxygene': 'Oxygene',
'parser3': 'Parser3',
'perl': 'Perl',
'pf': 'PF',
'pgsql': 'PostgreSQL',
'php': 'PHP',
'plaintext': 'Plain Text',
'pony': 'Pony',
'powershell': 'PowerShell',
'processing': 'Processing',
'profile': 'Profile',
'prolog': 'Prolog',
'properties': 'Properties',
'protobuf': 'Protocol Buffers',
'puppet': 'Puppet',
'purebasic': 'PureBASIC',
'python': 'Python',
'q': 'Q',
'qml': 'QML',
'r': 'R',
'reasonml': 'ReasonML',
'rib': 'RenderMan RIB',
'roboconf': 'Roboconf',
'routeros': 'RouterOS',
'rsl': 'RSL',
'ruby': 'Ruby',
'ruleslanguage': 'Rules Language',
'rust': 'Rust',
'sas': 'SAS',
'scala': 'Scala',
'scheme': 'Scheme',
'scilab': 'Scilab',
'scss': 'SCSS',
'shell': 'Shell',
'smali': 'Smali',
'smalltalk': 'Smalltalk',
'sml': 'SML',
'sqf': 'SQF',
'sql': 'SQL',
'stan': 'Stan',
'stata': 'Stata',
'step21': 'STEP 21',
'stylus': 'Stylus',
'subunit': 'SubUnit',
'swift': 'Swift',
'taggerscript': 'TaggerScript',
'tap': 'TAP',
'tcl': 'Tcl',
'tex': 'TeX',
'thrift': 'Thrift',
'tp': 'TP',
'twig': 'Twig',
'typescript': 'TypeScript',
'vala': 'Vala',
'vbnet': 'VB.NET',
'vbscript-html': 'VBScript HTML',
'vbscript': 'VBScript',
'verilog': 'Verilog',
'vhdl': 'VHDL',
'vim': 'Vim',
'x86asm': 'x86 Assembly',
'xl': 'XL',
'xml': 'XML',
'xquery': 'XQuery',
'yaml': 'YAML',
'zephir': 'Zephir',
'vue': 'Vue',
'graphql': 'GraphQL',
'gn': 'Gn',
'solidity': 'Solidity',
'txt': 'Plain Text',
'log': 'Log',
'cfg': 'Config',
'conf': 'Config',
'csv': 'CSV',
'h': 'C/C++ Header',
'hpp': 'C++ Header',
'cc': 'C++',
'cxx': 'C++',
'js': 'JavaScript',
'jsx': 'JavaScript JSX',
'ts': 'TypeScript',
'tsx': 'TypeScript JSX',
'py': 'Python',
'rs': 'Rust',
'rb': 'Ruby',
'kt': 'Kotlin',
'kts': 'Kotlin Script',
'html': 'HTML',
'htm': 'HTML',
'yml': 'YAML',
'sh': 'Bash',
'zsh': 'Zsh',
'crt': 'Plain Text',
'pem': 'Plain Text',
};
}
+86
View File
@@ -0,0 +1,86 @@
/// Cloudreve obfuscated thumbnail URL decoder.
/// Ported from the Go reference implementation in the API documentation.
class TimeFlowDecoder {
/// Decode an obfuscated time-flow string.
/// Tries current time, then ±1000ms to account for clock drift.
/// Returns null if all attempts fail.
static String? decodeTimeFlowString(String str) {
if (str.isEmpty) return null;
final timeNow = DateTime.now().millisecondsSinceEpoch;
for (final offset in [0, -1000, 1000]) {
final result = _decodeTimeFlowStringTime(str, timeNow + offset);
if (result != null) return result;
}
return null;
}
static String? _decodeTimeFlowStringTime(String str, int timeNowMillis) {
if (str.isEmpty) return null;
int timeNow = timeNowMillis ~/ 1000;
final timeNowBackup = timeNow;
final timeDigits = <int>[];
if (timeNow == 0) {
timeDigits.add(0);
} else {
int tempTime = timeNow;
while (tempTime > 0) {
timeDigits.add(tempTime % 10);
tempTime = tempTime ~/ 10;
}
}
final res = str.split('');
var secret = str.split('');
var add = secret.length % 2 == 0;
var timeDigitIndex = (secret.length - 1) % timeDigits.length;
final l = secret.length;
for (int pos = 0; pos < l; pos++) {
final targetIndex = l - 1 - pos;
int newIndex = targetIndex;
if (add) {
newIndex += timeDigits[timeDigitIndex] * timeDigitIndex;
} else {
newIndex = 2 * timeDigitIndex * timeDigits[timeDigitIndex] - newIndex;
}
if (newIndex < 0) {
newIndex = newIndex.abs();
}
newIndex = newIndex % secret.length;
res[targetIndex] = secret[newIndex];
// Swap secret[newIndex] with last element, then shrink
final lastSecretIndex = secret.length - 1;
final a = secret[lastSecretIndex];
final b = secret[newIndex];
secret[newIndex] = a;
secret[lastSecretIndex] = b;
secret = secret.sublist(0, lastSecretIndex);
add = !add;
timeDigitIndex--;
if (timeDigitIndex < 0) {
timeDigitIndex = timeDigits.length - 1;
}
}
final resStr = res.join('');
final pipeIndex = resStr.indexOf('|');
if (pipeIndex < 0) return null;
final timestampPart = resStr.substring(0, pipeIndex);
if (timestampPart != timeNowBackup.toString()) return null;
return resStr.substring(pipeIndex + 1);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
/// 视频全屏状态通知器,用于在 main.dart 中隐藏桌面端标题栏
final videoFullscreenNotifier = ValueNotifier<bool>(false);
/// 进入视频全屏(系统级)
Future<void> enterVideoFullscreen() async {
videoFullscreenNotifier.value = true;
if (Platform.isAndroid || Platform.isIOS) {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
),
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]),
]);
} else if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
await windowManager.setFullScreen(true);
}
}
/// 退出视频全屏(系统级)
Future<void> exitVideoFullscreen() async {
videoFullscreenNotifier.value = false;
if (Platform.isAndroid || Platform.isIOS) {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
),
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
]);
} else if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
await windowManager.setFullScreen(false);
}
}
+44
View File
@@ -0,0 +1,44 @@
/// 字符串验证器
class StringValidator {
/// 验证邮箱
static String? validateEmail(String? value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return '请输入有效的邮箱地址';
}
return null;
}
/// 验证密码
static String? validatePassword(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码长度至少为6位';
}
return null;
}
/// 验证必填
static String? validateRequired(String? value, {String message = '此字段不能为空'}) {
if (value == null || value.isEmpty) {
return message;
}
return null;
}
/// 验证昵称
static String? validateNickname(String? value) {
if (value == null || value.isEmpty) {
return '请输入昵称';
}
if (value.length > 50) {
return '昵称长度不能超过50个字符';
}
return null;
}
}
+198
View File
@@ -0,0 +1,198 @@
// 管理员相关数据模型
class PaginationModel {
final int page;
final int pageSize;
final int totalItems;
PaginationModel({
required this.page,
required this.pageSize,
required this.totalItems,
});
factory PaginationModel.fromJson(Map<String, dynamic> json) {
return PaginationModel(
page: json['page'] as int? ?? 0,
pageSize: json['page_size'] as int? ?? 10,
totalItems: json['total_items'] as int? ?? 0,
);
}
}
/// 管理员用户组模型
class AdminGroupModel {
final int id;
final String name;
final String? permissions;
final Map<String, dynamic>? settings;
final int? maxStorage;
final int? storagePolicyId;
final DateTime? createdAt;
final DateTime? updatedAt;
final Map<String, dynamic>? storagePolicy;
final int? totalUsers;
AdminGroupModel({
required this.id,
required this.name,
this.permissions,
this.settings,
this.maxStorage,
this.storagePolicyId,
this.createdAt,
this.updatedAt,
this.storagePolicy,
this.totalUsers,
});
factory AdminGroupModel.fromJson(Map<String, dynamic> json) {
Map<String, dynamic>? storagePolicy;
if (json['edges'] is Map && json['edges']['storage_policies'] is Map) {
storagePolicy = json['edges']['storage_policies'] as Map<String, dynamic>;
}
return AdminGroupModel(
id: json['id'] as int,
name: json['name'] as String,
permissions: json['permissions'] as String?,
settings: json['settings'] as Map<String, dynamic>?,
maxStorage: json['max_storage'] as int?,
storagePolicyId: json['storage_policy_id'] as int?,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
storagePolicy: storagePolicy,
totalUsers: json['total_users'] as int?,
);
}
/// 格式化最大存储空间
String get formattedMaxStorage {
if (maxStorage == null) return '无限制';
return _formatBytes(maxStorage!);
}
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 AdminUserModel {
final int id;
final String email;
final String nick;
final String status;
final String? avatar;
final int? storage;
final DateTime? createdAt;
final DateTime? updatedAt;
final String? hashId;
final int? groupUsers;
final AdminGroupModel? group;
AdminUserModel({
required this.id,
required this.email,
required this.nick,
required this.status,
this.avatar,
this.storage,
this.createdAt,
this.updatedAt,
this.hashId,
this.groupUsers,
this.group,
});
factory AdminUserModel.fromJson(Map<String, dynamic> json) {
AdminGroupModel? group;
if (json['edges'] is Map && json['edges']['group'] is Map) {
group = AdminGroupModel.fromJson(
json['edges']['group'] as Map<String, dynamic>);
}
return AdminUserModel(
id: json['id'] as int,
email: json['email'] as String? ?? '',
nick: json['nick'] as String? ?? '',
status: json['status'] as String? ?? 'active',
avatar: json['avatar'] as String?,
storage: json['storage'] as int?,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
hashId: json['hash_id'] as String?,
groupUsers: json['group_users'] as int?,
group: group,
);
}
/// 获取用户头像首字母
String get initial => nick.isNotEmpty ? nick[0].toUpperCase() : 'U';
/// 格式化已用存储空间
String get formattedStorage {
if (storage == null) return '未知';
return _formatBytes(storage!);
}
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 AdminGroupListResponse {
final List<AdminGroupModel> groups;
final PaginationModel pagination;
AdminGroupListResponse({required this.groups, required this.pagination});
factory AdminGroupListResponse.fromJson(Map<String, dynamic> json) {
return AdminGroupListResponse(
groups: (json['groups'] as List?)
?.map((e) => AdminGroupModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
pagination: PaginationModel.fromJson(
json['pagination'] as Map<String, dynamic>? ?? {}),
);
}
}
/// 管理员用户列表响应
class AdminUserListResponse {
final List<AdminUserModel> users;
final PaginationModel pagination;
AdminUserListResponse({required this.users, required this.pagination});
factory AdminUserListResponse.fromJson(Map<String, dynamic> json) {
return AdminUserListResponse(
users: (json['users'] as List?)
?.map((e) => AdminUserModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
pagination: PaginationModel.fromJson(
json['pagination'] as Map<String, dynamic>? ?? {}),
);
}
}
+96
View File
@@ -0,0 +1,96 @@
/// 缓存设置模型
class CacheSettingsModel {
/// 最大缓存大小(字节)
final int maxCacheSize;
/// 缓存过期时间(毫秒)
final int cacheExpireDuration;
/// 是否自动清理最旧文件
final bool autoCleanOldFiles;
CacheSettingsModel({
this.maxCacheSize = 500 * 1024 * 1024, // 默认500MB
this.cacheExpireDuration = 7 * 24 * 60 * 60 * 1000, // 默认7天
this.autoCleanOldFiles = true,
});
factory CacheSettingsModel.fromJson(Map<String, dynamic> json) {
return CacheSettingsModel(
maxCacheSize: json['max_cache_size'] as int? ?? 500 * 1024 * 1024,
cacheExpireDuration:
json['cache_expire_duration'] as int? ?? 7 * 24 * 60 * 60 * 1000,
autoCleanOldFiles: json['auto_clean_old_files'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'max_cache_size': maxCacheSize,
'cache_expire_duration': cacheExpireDuration,
'auto_clean_old_files': autoCleanOldFiles,
};
}
/// 获取人类可读的最大缓存大小
String get maxCacheSizeReadable {
return _formatBytes(maxCacheSize);
}
/// 获取人类可读的缓存过期时间
String get cacheExpireDurationReadable {
final days = cacheExpireDuration ~/ (24 * 60 * 60 * 1000);
if (days > 0) {
return '$days天';
}
final hours = cacheExpireDuration ~/ (60 * 60 * 1000);
if (hours > 0) {
return '$hours小时';
}
final minutes = cacheExpireDuration ~/ (60 * 1000);
return '$minutes分钟';
}
String _formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
} else if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
/// 从MB创建
static CacheSettingsModel fromMB(int maxCacheSizeMB) {
return CacheSettingsModel(maxCacheSize: maxCacheSizeMB * 1024 * 1024);
}
/// 从天数创建
static CacheSettingsModel fromDays(int days) {
return CacheSettingsModel(
cacheExpireDuration: days * 24 * 60 * 60 * 1000,
);
}
/// 创建副本
CacheSettingsModel copyWith({
int? maxCacheSize,
int? cacheExpireDuration,
bool? autoCleanOldFiles,
}) {
return CacheSettingsModel(
maxCacheSize: maxCacheSize ?? this.maxCacheSize,
cacheExpireDuration: cacheExpireDuration ?? this.cacheExpireDuration,
autoCleanOldFiles: autoCleanOldFiles ?? this.autoCleanOldFiles,
);
}
/// 获取可用预设大小选项
static List<int> get availableSizes => [100, 250, 500, 1000, 2000]; // MB
/// 获取可用预设过期时间选项
static List<int> get availableDurations => [1, 3, 7, 15, 30]; // 天
}
+40
View File
@@ -0,0 +1,40 @@
/// WebDAV 账户模型
class DavAccountModel {
final String id;
final DateTime createdAt;
final String name;
final String uri;
final String password;
final String options;
DavAccountModel({
required this.id,
required this.createdAt,
required this.name,
required this.uri,
required this.password,
required this.options,
});
factory DavAccountModel.fromJson(Map<String, dynamic> json) {
return DavAccountModel(
id: json['id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
name: json['name'] as String,
uri: json['uri'] as String,
password: json['password'] as String,
options: json['options'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'created_at': createdAt.toIso8601String(),
'name': name,
'uri': uri,
'password': password,
'options': options,
};
}
}
+154
View File
@@ -0,0 +1,154 @@
import '../../core/utils/app_logger.dart';
/// 下载状态
enum DownloadStatus {
waiting, // 等待中
downloading, // 下载中
completed, // 已完成
paused, // 已暂停
failed, // 失败
cancelled, // 已取消
}
/// 下载任务模型
class DownloadTaskModel {
final String id;
final String fileName;
final String fileUri; // cloudreve URI
final String? downloadUrl; // 实际下载URL
final int fileSize;
final String savePath;
String? backgroundTaskId; // background_downloader 的 task ID(可变,用于重启后恢复映射)
DownloadStatus status;
int downloadedBytes;
int speed; // 下载速度,字节/秒
bool waitingForWifi; // 是否在等待WiFi(非持久化)
final DateTime createdAt;
DateTime? completedAt;
String? errorMessage;
double get progress => fileSize > 0 ? downloadedBytes / fileSize : 0.0;
int get remainingBytes => fileSize - downloadedBytes;
String get speedText {
if (speed <= 0) return '';
if (speed < 1024) return '$speed B/s';
if (speed < 1024 * 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s';
return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
String get progressText {
if (status == DownloadStatus.completed) {
return '100%';
}
AppLogger.d('DownloadTaskModel -> 已经初始化, status: $status');
final percent = (progress * 100).toStringAsFixed(1);
AppLogger.d('DownloadTaskModel -> 已经初始化, percent: $percent');
return '$percent%';
}
String get statusText {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? '等待WiFi' : '等待中';
case DownloadStatus.downloading:
return '下载中';
case DownloadStatus.completed:
return '已完成';
case DownloadStatus.paused:
return '已暂停';
case DownloadStatus.failed:
return '下载失败';
case DownloadStatus.cancelled:
return '已取消';
}
}
DownloadTaskModel({
required this.id,
required this.fileName,
required this.fileUri,
this.downloadUrl,
required this.fileSize,
required this.savePath,
this.backgroundTaskId,
this.status = DownloadStatus.waiting,
this.downloadedBytes = 0,
this.speed = 0,
this.waitingForWifi = false,
DateTime? createdAt,
this.completedAt,
this.errorMessage,
}) : createdAt = createdAt ?? DateTime.now();
DownloadTaskModel copyWith({
String? id,
String? fileName,
String? fileUri,
String? downloadUrl,
int? fileSize,
String? savePath,
String? backgroundTaskId,
DownloadStatus? status,
int? downloadedBytes,
int? speed,
bool? waitingForWifi,
DateTime? createdAt,
DateTime? completedAt,
String? errorMessage,
}) {
return DownloadTaskModel(
id: id ?? this.id,
fileName: fileName ?? this.fileName,
fileUri: fileUri ?? this.fileUri,
downloadUrl: downloadUrl ?? this.downloadUrl,
fileSize: fileSize ?? this.fileSize,
savePath: savePath ?? this.savePath,
backgroundTaskId: backgroundTaskId ?? this.backgroundTaskId,
status: status ?? this.status,
downloadedBytes: downloadedBytes ?? this.downloadedBytes,
speed: speed ?? this.speed,
waitingForWifi: waitingForWifi ?? this.waitingForWifi,
createdAt: createdAt ?? this.createdAt,
completedAt: completedAt ?? this.completedAt,
errorMessage: errorMessage ?? this.errorMessage,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'fileName': fileName,
'fileUri': fileUri,
'downloadUrl': downloadUrl,
'fileSize': fileSize,
'savePath': savePath,
'backgroundTaskId': backgroundTaskId,
'status': status.index,
'downloadedBytes': downloadedBytes,
'speed': speed,
'createdAt': createdAt.toIso8601String(),
'completedAt': completedAt?.toIso8601String(),
'errorMessage': errorMessage,
};
}
factory DownloadTaskModel.fromJson(Map<String, dynamic> json) {
return DownloadTaskModel(
id: json['id'] as String,
fileName: json['fileName'] as String,
fileUri: json['fileUri'] as String,
downloadUrl: json['downloadUrl'] as String?,
fileSize: json['fileSize'] as int,
savePath: json['savePath'] as String,
backgroundTaskId: json['backgroundTaskId'] as String?,
status: DownloadStatus.values[json['status'] as int? ?? 0],
downloadedBytes: json['downloadedBytes'] as int? ?? 0,
speed: json['speed'] as int? ?? 0,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: null,
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
errorMessage: json['errorMessage'] as String?,
);
}
}
+375
View File
@@ -0,0 +1,375 @@
import 'share_model.dart';
/// 文件模型
class FileModel {
final int type; // 0:文件, 1:文件夹
final String id;
final String name;
final DateTime createdAt;
final DateTime updatedAt;
final int size;
final String path;
final Map<String, dynamic>? metadata;
final String? permission;
final String? primaryEntity;
final String? capability;
final bool? owned;
FileModel({
required this.type,
required this.id,
required this.name,
required this.createdAt,
required this.updatedAt,
required this.size,
required this.path,
this.metadata,
this.permission,
this.primaryEntity,
this.capability,
this.owned,
});
factory FileModel.fromJson(Map<String, dynamic> json) {
return FileModel(
type: json['type'] as int,
id: json['id'] as String,
name: json['name'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
size: (json['size'] as num?)?.toInt() ?? 0,
path: json['path'] as String,
metadata: json['metadata'] as Map<String, dynamic>?,
permission: json['permission'] as String?,
primaryEntity: json['primary_entity'] as String?,
capability: json['capability'] as String?,
owned: json['owned'] as bool?,
);
}
Map<String, dynamic> toJson() {
return {
'type': type,
'id': id,
'name': name,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'size': size,
'path': path,
'metadata': metadata,
'permission': permission,
'primary_entity': primaryEntity,
'capability': capability,
'owned': owned,
};
}
bool get isFile => type == 0;
bool get isFolder => type == 1;
FileModel copyWith({
int? type,
String? id,
String? name,
DateTime? createdAt,
DateTime? updatedAt,
int? size,
String? path,
Map<String, dynamic>? metadata,
String? permission,
String? primaryEntity,
String? capability,
bool? owned,
}) {
return FileModel(
type: type ?? this.type,
id: id ?? this.id,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
size: size ?? this.size,
path: path ?? this.path,
metadata: metadata ?? this.metadata,
permission: permission ?? this.permission,
primaryEntity: primaryEntity ?? this.primaryEntity,
capability: capability ?? this.capability,
owned: owned ?? this.owned,
);
}
/// 获取相对于 cloudreve://my 的路径
/// 例如: cloudreve://my/Games -> /Games
/// cloudreve://my/sub/folder -> /sub/folder
String get relativePath {
if (!path.startsWith('cloudreve://my')) {
// 如果不是 cloudreve://my 开头,返回空
return '/';
}
final prefix = 'cloudreve://my';
final relative = path.substring(prefix.length);
return relative.isEmpty ? '/' : relative;
}
}
/// 文件夹摘要模型
class FolderSummaryModel {
final int size;
final int files;
final int folders;
final bool completed;
final DateTime calculatedAt;
FolderSummaryModel({
required this.size,
required this.files,
required this.folders,
required this.completed,
required this.calculatedAt,
});
factory FolderSummaryModel.fromJson(Map<String, dynamic> json) {
return FolderSummaryModel(
size: (json['size'] as num?)?.toInt() ?? 0,
files: (json['files'] as num?)?.toInt() ?? 0,
folders: (json['folders'] as num?)?.toInt() ?? 0,
completed: json['completed'] as bool,
calculatedAt: DateTime.parse(json['calculated_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'size': size,
'files': files,
'folders': folders,
'completed': completed,
'calculated_at': calculatedAt.toIso8601String(),
};
}
}
/// 扩展信息模型
class ExtendedInfoModel {
final StoragePolicyModel? storagePolicy;
final int? storageUsed;
final List<ShareModel>? shares;
final List<EntityModel>? entities;
final List<DirectLinkModel>? directLinks;
ExtendedInfoModel({
this.storagePolicy,
this.storageUsed,
this.shares,
this.entities,
this.directLinks,
});
factory ExtendedInfoModel.fromJson(Map<String, dynamic> json) {
return ExtendedInfoModel(
storagePolicy: json['storage_policy'] is Map<String, dynamic>
? StoragePolicyModel.fromJson(json['storage_policy'] as Map<String, dynamic>)
: null,
storageUsed: (json['storage_used'] as num?)?.toInt(),
shares: json['shares'] != null
? (json['shares'] as List)
.map((e) => ShareModel.fromJson(e as Map<String, dynamic>))
.toList()
: null,
entities: json['entities'] != null
? (json['entities'] as List)
.map((e) => EntityModel.fromJson(e as Map<String, dynamic>))
.toList()
: null,
directLinks: json['direct_links'] != null
? (json['direct_links'] as List)
.map((e) => DirectLinkModel.fromJson(e as Map<String, dynamic>))
.toList()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'storage_policy': storagePolicy?.toJson(),
'storage_used': storageUsed,
'shares': shares?.map((e) => e.toJson()).toList(),
'entities': entities?.map((e) => e.toJson()).toList(),
'direct_links': directLinks?.map((e) => e.toJson()).toList(),
};
}
}
/// 存储策略模型
class StoragePolicyModel {
final String id;
final String name;
final String type;
final int? maxSize;
StoragePolicyModel({
required this.id,
required this.name,
required this.type,
this.maxSize,
});
factory StoragePolicyModel.fromJson(Map<String, dynamic> json) {
return StoragePolicyModel(
id: json['id'] as String,
name: json['name'] as String,
type: json['type'] as String,
maxSize: (json['max_size'] as num?)?.toInt(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'type': type,
'max_size': maxSize,
};
}
}
/// 实体创建者模型
class EntityCreatedByModel {
final String id;
final String nickname;
final String? avatar;
final DateTime createdAt;
EntityCreatedByModel({
required this.id,
required this.nickname,
this.avatar,
required this.createdAt,
});
factory EntityCreatedByModel.fromJson(Map<String, dynamic> json) {
return EntityCreatedByModel(
id: json['id'] as String,
nickname: json['nickname'] as String,
avatar: json['avatar'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nickname': nickname,
'avatar': avatar,
'created_at': createdAt.toIso8601String(),
};
}
}
/// 实体模型(文件版本/Blob)
class EntityModel {
final String id;
final int type;
final DateTime createdAt;
final int size;
final String? encryptedWith;
final StoragePolicyModel? storagePolicy;
final EntityCreatedByModel? createdBy;
EntityModel({
required this.id,
required this.type,
required this.createdAt,
required this.size,
this.encryptedWith,
this.storagePolicy,
this.createdBy,
});
factory EntityModel.fromJson(Map<String, dynamic> json) {
return EntityModel(
id: json['id'] as String,
type: json['type'] as int,
createdAt: DateTime.parse(json['created_at'] as String),
size: (json['size'] as num?)?.toInt() ?? 0,
encryptedWith: json['encrypted_with'] as String?,
storagePolicy: json['storage_policy'] is Map<String, dynamic>
? StoragePolicyModel.fromJson(json['storage_policy'] as Map<String, dynamic>)
: null,
createdBy: json['created_by'] is Map<String, dynamic>
? EntityCreatedByModel.fromJson(json['created_by'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'created_at': createdAt.toIso8601String(),
'size': size,
'encrypted_with': encryptedWith,
'storage_policy': storagePolicy?.toJson(),
'created_by': createdBy?.toJson(),
};
}
}
/// 直链模型
class DirectLinkModel {
final String id;
final DateTime createdAt;
final String url;
final int downloaded;
DirectLinkModel({
required this.id,
required this.createdAt,
required this.url,
required this.downloaded,
});
factory DirectLinkModel.fromJson(Map<String, dynamic> json) {
return DirectLinkModel(
id: json['id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
url: json['url'] as String,
downloaded: (json['downloaded'] as num?)?.toInt() ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'created_at': createdAt.toIso8601String(),
'url': url,
'downloaded': downloaded,
};
}
}
/// 文件详情模型(/file/info 接口返回)
class FileInfoModel {
final FileModel file;
final FolderSummaryModel? folderSummary;
final ExtendedInfoModel? extendedInfo;
FileInfoModel({
required this.file,
this.folderSummary,
this.extendedInfo,
});
factory FileInfoModel.fromJson(Map<String, dynamic> json) {
return FileInfoModel(
file: FileModel.fromJson(json),
folderSummary: json['folder_summary'] is Map<String, dynamic>
? FolderSummaryModel.fromJson(json['folder_summary'] as Map<String, dynamic>)
: null,
extendedInfo: json['extended_info'] is Map<String, dynamic>
? ExtendedInfoModel.fromJson(json['extended_info'] as Map<String, dynamic>)
: null,
);
}
}
@@ -0,0 +1,301 @@
/// 离线下载任务状态
enum RemoteDownloadStatus {
queued,
running,
completed,
error,
suspending,
suspended;
static RemoteDownloadStatus fromString(String value) {
return RemoteDownloadStatus.values.firstWhere(
(e) => e.name == value,
orElse: () => RemoteDownloadStatus.queued,
);
}
String get text {
switch (this) {
case RemoteDownloadStatus.queued:
return '排队中';
case RemoteDownloadStatus.running:
return '下载中';
case RemoteDownloadStatus.completed:
return '已完成';
case RemoteDownloadStatus.error:
return '出错';
case RemoteDownloadStatus.suspending:
return '暂停中';
case RemoteDownloadStatus.suspended:
return '已暂停';
}
}
bool get isOngoing =>
this == RemoteDownloadStatus.queued ||
this == RemoteDownloadStatus.running ||
this == RemoteDownloadStatus.suspending ||
this == RemoteDownloadStatus.suspended;
}
/// 种子文件信息
class DownloadFileInfo {
final int index;
final String name;
final int size;
final double progress;
final bool selected;
const DownloadFileInfo({
required this.index,
required this.name,
required this.size,
required this.progress,
required this.selected,
});
factory DownloadFileInfo.fromJson(Map<String, dynamic> json) {
return DownloadFileInfo(
index: json['index'] as int,
name: json['name'] as String,
size: json['size'] as int,
progress: (json['progress'] as num).toDouble(),
selected: json['selected'] as bool? ?? true,
);
}
}
/// 下载详情(aria2 返回的信息)
class DownloadInfo {
final String name;
final String state;
final int total;
final int downloaded;
final int downloadSpeed;
final int uploaded;
final int uploadSpeed;
final String hash;
final List<DownloadFileInfo> files;
final int numPieces;
const DownloadInfo({
required this.name,
required this.state,
required this.total,
required this.downloaded,
required this.downloadSpeed,
required this.uploaded,
required this.uploadSpeed,
required this.hash,
required this.files,
this.numPieces = 0,
});
double get progress => total > 0 ? downloaded / total : 0.0;
String get speedText {
if (downloadSpeed <= 0) return '';
if (downloadSpeed < 1024) return '$downloadSpeed B/s';
if (downloadSpeed < 1024 * 1024) {
return '${(downloadSpeed / 1024).toStringAsFixed(1)} KB/s';
}
return '${(downloadSpeed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
factory DownloadInfo.fromJson(Map<String, dynamic> json) {
final filesList = json['files'] as List<dynamic>? ?? [];
return DownloadInfo(
name: json['name'] as String? ?? '',
state: json['state'] as String? ?? '',
total: (json['total'] as num?)?.toInt() ?? 0,
downloaded: (json['downloaded'] as num?)?.toInt() ?? 0,
downloadSpeed: (json['download_speed'] as num?)?.toInt() ?? 0,
uploaded: (json['uploaded'] as num?)?.toInt() ?? 0,
uploadSpeed: (json['upload_speed'] as num?)?.toInt() ?? 0,
hash: json['hash'] as String? ?? '',
files: filesList
.map((f) => DownloadFileInfo.fromJson(f as Map<String, dynamic>))
.toList(),
numPieces: (json['num_pieces'] as num?)?.toInt() ?? 0,
);
}
}
/// 处理节点信息
class TaskNode {
final String id;
final String name;
final String type;
const TaskNode({
required this.id,
required this.name,
required this.type,
});
String get displayName {
switch (type) {
case 'master':
return '$name(本机)';
default:
return name;
}
}
factory TaskNode.fromJson(Map<String, dynamic> json) {
return TaskNode(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
type: json['type'] as String? ?? '',
);
}
}
/// 任务摘要
class TaskSummary {
final String phase;
final String dst;
final int failed;
final String src;
final String srcStr;
final DownloadInfo? download;
const TaskSummary({
required this.phase,
required this.dst,
required this.failed,
required this.src,
required this.srcStr,
this.download,
});
factory TaskSummary.fromJson(Map<String, dynamic> json) {
return TaskSummary(
phase: json['phase'] as String? ?? '',
dst: json['props']?['dst'] as String? ?? '',
failed: (json['props']?['failed'] as num?)?.toInt() ?? 0,
src: json['props']?['src'] as String? ?? '',
srcStr: json['props']?['src_str'] as String? ?? '',
download: json['props']?['download'] != null
? DownloadInfo.fromJson(
json['props']!['download'] as Map<String, dynamic>)
: null,
);
}
}
/// 离线下载任务模型
class RemoteDownloadTaskModel {
final String id;
final RemoteDownloadStatus status;
final String type;
final DateTime createdAt;
final DateTime updatedAt;
final int duration;
final String? error;
final TaskNode? node;
final TaskSummary? summary;
final int resumeTime;
final int retryCount;
const RemoteDownloadTaskModel({
required this.id,
required this.status,
required this.type,
required this.createdAt,
required this.updatedAt,
required this.duration,
this.error,
this.node,
this.summary,
this.resumeTime = 0,
this.retryCount = 0,
});
String getFileNameFromUrl(String url) {
if (url.isEmpty) return '';
try {
final uri = Uri.parse(url);
final segments = uri.pathSegments;
if (segments.isEmpty) return '';
final fileName = segments.last;
return fileName.isNotEmpty ? fileName : '';
} catch (e) {
return '';
}
}
/// 显示名称:优先使用 download.name,其次 srcStr,最后 id
String get displayName {
final dlName = summary?.download?.name;
if (dlName != null && dlName.isNotEmpty) {
return dlName;
} else {
final srcStr = summary?.srcStr;
if (srcStr == null || srcStr.isEmpty) return id;
final fileName = getFileNameFromUrl(srcStr);
if (fileName.isEmpty) return id;
return fileName;
}
}
/// 格式化耗时(duration 单位为毫秒)
String get durationText {
final seconds = duration ~/ 1000;
if (seconds <= 0) return '-';
if (seconds < 60) return '$seconds';
if (seconds < 3600) return '${seconds ~/ 60}${seconds % 60}';
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
return '$hours 小时 $minutes';
}
/// 输入源显示文本
String get srcDisplayText {
final srcStr = summary?.srcStr;
if (srcStr != null && srcStr.isNotEmpty) return srcStr;
final src = summary?.src;
if (src!.isNotEmpty) {
// 从 cloudreve URI 提取文件名
final parts = src.split('/');
return parts.isNotEmpty ? parts.last : src;
}
return '-';
}
/// 输出目标显示文本(从 cloudreve URI 转换为相对路径)
String get dstDisplayText {
final dst = summary?.dst ?? '';
if (dst.isEmpty) return '-';
if (dst.startsWith('cloudreve://my')) {
final relative = dst.replaceFirst('cloudreve://my', '');
return relative.isEmpty ? '/' : relative;
}
return dst;
}
factory RemoteDownloadTaskModel.fromJson(Map<String, dynamic> json) {
return RemoteDownloadTaskModel(
id: json['id'] as String,
status: RemoteDownloadStatus.fromString(json['status'] as String),
type: json['type'] as String? ?? '',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String).toLocal()
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String).toLocal()
: DateTime.now(),
duration: (json['duration'] as num?)?.toInt() ?? 0,
error: json['error'] as String?,
node: json['node'] != null
? TaskNode.fromJson(json['node'] as Map<String, dynamic>)
: null,
summary: json['summary'] != null
? TaskSummary.fromJson(json['summary'] as Map<String, dynamic>)
: null,
resumeTime: (json['resume_time'] as num?)?.toInt() ?? 0,
retryCount: (json['retry_count'] as num?)?.toInt() ?? 0,
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:cloudreve4_flutter/data/models/user_model.dart';
/// 服务器模型
class ServerModel {
final String label;
final String baseUrl;
bool rememberMe;
String? email;
String? password;
UserModel? user;
ServerModel({
required this.label,
required this.baseUrl,
this.rememberMe = true,
this.email,
this.password,
this.user,
});
ServerModel copyWith({
String? label,
String? baseUrl,
bool? rememberMe,
String? email,
String? password,
UserModel? user,
}) {
return ServerModel(
label: label ?? this.label,
baseUrl: baseUrl ?? this.baseUrl,
rememberMe: rememberMe ?? this.rememberMe,
email: email ?? this.email,
password: password ?? this.password,
user: user ?? this.user,
);
}
factory ServerModel.fromJson(Map<String, dynamic> json) {
return ServerModel(
label: json['label'] as String,
baseUrl: json['baseUrl'] as String,
rememberMe: json['rememberMe'] as bool? ?? true,
email: json['email'] as String?,
password: json['password'] as String?,
user: json['user'] != null
? UserModel.fromJson(json['user'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'label': label,
'baseUrl': baseUrl,
'rememberMe': rememberMe,
'email': email,
'password': password,
'user': user?.toJson(),
};
}
}
+212
View File
@@ -0,0 +1,212 @@
/// 分享模型
class ShareModel {
final String id;
final String name;
final int visited;
final int? downloaded;
final int? price;
final bool unlocked;
final int sourceType;
final ShareOwner? owner;
final DateTime createdAt;
final DateTime? expires;
final bool expired;
final String url;
final int? size;
final SharePermissionSetting? permissionSetting;
final bool? isPrivate;
final String? password;
final bool? shareView;
final String? sourceUri;
final bool? showReadme;
final bool? passwordProtected;
ShareModel({
required this.id,
required this.name,
required this.visited,
this.downloaded,
this.price,
required this.unlocked,
required this.sourceType,
this.owner,
required this.createdAt,
this.expires,
required this.expired,
required this.url,
this.size,
this.permissionSetting,
this.isPrivate,
this.password,
this.shareView,
this.sourceUri,
this.showReadme,
this.passwordProtected,
});
factory ShareModel.fromJson(Map<String, dynamic> json) {
return ShareModel(
id: json['id'] as String,
name: json['name'] as String,
visited: (json['visited'] as num?)?.toInt() ?? 0,
downloaded: (json['downloaded'] as num?)?.toInt(),
price: (json['price'] as num?)?.toInt(),
unlocked: json['unlocked'] as bool? ?? false,
sourceType: (json['source_type'] as num?)?.toInt() ?? 0,
owner: json['owner'] is Map<String, dynamic>
? ShareOwner.fromJson(json['owner'] as Map<String, dynamic>)
: null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: DateTime.now(),
expires: json['expires'] != null
? DateTime.parse(json['expires'] as String)
: null,
expired: json['expired'] as bool? ?? false,
url: json['url'] as String? ?? '',
size: (json['size'] as num?)?.toInt(),
permissionSetting: json['permission_setting'] is Map<String, dynamic>
? SharePermissionSetting.fromJson(json['permission_setting'] as Map<String, dynamic>)
: null,
isPrivate: json['is_private'] as bool?,
password: json['password'] as String?,
shareView: json['share_view'] as bool?,
sourceUri: json['source_uri'] as String?,
showReadme: json['show_readme'] as bool?,
passwordProtected: json['password_protected'] as bool?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'visited': visited,
'downloaded': downloaded,
'price': price,
'unlocked': unlocked,
'source_type': sourceType,
'owner': owner?.toJson(),
'created_at': createdAt.toIso8601String(),
'expires': expires?.toIso8601String(),
'expired': expired,
'url': url,
'size': size,
'permission_setting': permissionSetting?.toJson(),
'is_private': isPrivate,
'password': password,
'share_view': shareView,
'source_uri': sourceUri,
'show_readme': showReadme,
'password_protected': passwordProtected,
};
}
bool get isFolder => sourceType == 1;
bool get isFile => sourceType == 0;
}
/// 分享所有者信息
class ShareOwner {
final String id;
final String? email;
final String nickname;
final DateTime createdAt;
final ShareOwnerGroup? group;
final String? shareLinksInProfile;
ShareOwner({
required this.id,
this.email,
required this.nickname,
required this.createdAt,
this.group,
this.shareLinksInProfile,
});
factory ShareOwner.fromJson(Map<String, dynamic> json) {
return ShareOwner(
id: json['id'] as String,
email: json['email'] as String?,
nickname: json['nickname'] as String,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: DateTime.now(),
group: json['group'] is Map<String, dynamic>
? ShareOwnerGroup.fromJson(json['group'] as Map<String, dynamic>)
: null,
shareLinksInProfile: json['share_links_in_profile'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'nickname': nickname,
'created_at': createdAt.toIso8601String(),
'group': group?.toJson(),
'share_links_in_profile': shareLinksInProfile,
};
}
}
/// 分享所有者所属组
class ShareOwnerGroup {
final String id;
final String name;
ShareOwnerGroup({required this.id, required this.name});
factory ShareOwnerGroup.fromJson(Map<String, dynamic> json) {
return ShareOwnerGroup(
id: json['id'] as String,
name: json['name'] as String,
);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
/// 权限设置
class SharePermissionSetting {
final String? sameGroup;
final String? other;
final String? anonymous;
final String? everyone;
final Map<String, String>? groupExplicit;
final Map<String, String>? userExplicit;
SharePermissionSetting({
this.sameGroup,
this.other,
this.anonymous,
this.everyone,
this.groupExplicit,
this.userExplicit,
});
factory SharePermissionSetting.fromJson(Map<String, dynamic> json) {
return SharePermissionSetting(
sameGroup: json['same_group'] as String?,
other: json['other'] as String?,
anonymous: json['anonymous'] as String?,
everyone: json['everyone'] as String?,
groupExplicit: (json['group_explicit'] as Map<String, dynamic>?)
?.cast<String, String>(),
userExplicit: (json['user_explicit'] as Map<String, dynamic>?)
?.cast<String, String>(),
);
}
Map<String, dynamic> toJson() {
return {
'same_group': sameGroup,
'other': other,
'anonymous': anonymous,
'everyone': everyone,
'group_explicit': groupExplicit,
'user_explicit': userExplicit,
};
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'dart:io';
/// 上传状态
enum UploadStatus {
waiting, // 等待中
uploading, // 上传中
completed, // 已完成
paused, // 已暂停
failed, // 失败
cancelled, // 已取消
}
/// 上传会话模型(从 API 返回的上传会话信息)
class UploadSessionModel {
final String sessionId;
final String? uploadId;
final int chunkSize;
final int expires;
final List<String>? uploadUrls;
final String? credential;
final String? completeUrl;
final StoragePolicyModel storagePolicy;
final String? mimeType;
final String? uploadPolicy;
final String? callbackSecret;
UploadSessionModel({
required this.sessionId,
this.uploadId,
required this.chunkSize,
required this.expires,
this.uploadUrls,
this.credential,
this.completeUrl,
required this.storagePolicy,
this.mimeType,
this.uploadPolicy,
this.callbackSecret,
});
factory UploadSessionModel.fromJson(Map<String, dynamic> json) {
return UploadSessionModel(
sessionId: json['session_id'] as String,
uploadId: json['upload_id'] as String?,
chunkSize: json['chunk_size'] as int,
expires: json['expires'] as int,
uploadUrls: json['upload_urls'] != null
? (json['upload_urls'] as List).map((e) => e as String).toList()
: null,
credential: json['credential'] as String?,
completeUrl: json['completeURL'] as String?,
storagePolicy: StoragePolicyModel.fromJson(json['storage_policy'] as Map<String, dynamic>),
mimeType: json['mime_type'] as String?,
uploadPolicy: json['upload_policy'] as String?,
callbackSecret: json['callback_secret'] as String?,
);
}
/// 是否支持分片上传
bool get isMultipartEnabled => chunkSize > 0;
/// 是否使用中继上传(上传到 Cloudreve 服务器)
bool get isRelayUpload => storagePolicy.relay == true || storagePolicy.type == 'local';
}
/// 存储策略模型
class StoragePolicyModel {
final String id;
final String name;
final String type;
final int maxSize;
final bool? relay;
StoragePolicyModel({
required this.id,
required this.name,
required this.type,
required this.maxSize,
this.relay,
});
factory StoragePolicyModel.fromJson(Map<String, dynamic> json) {
return StoragePolicyModel(
id: json['id'] as String,
name: json['name'] as String,
type: json['type'] as String,
maxSize: json['max_size'] as int,
relay: json['relay'] as bool?,
);
}
}
/// 上传任务模型
class UploadTaskModel {
final String id;
final File file;
final String fileName;
final int fileSize;
final String targetPath; // 目标路径,例如 cloudreve://my/subfolder
final DateTime createdAt;
DateTime? completedAt;
final UploadStatus status;
final int uploadedBytes;
final double progress;
final int uploadedChunks; // 已上传的分片数量
final int totalChunks; // 总分片数量
final String? errorMessage;
final UploadSessionModel? session; // 上传会话信息
final int speed; // 上传速度,字节/秒
UploadTaskModel({
required this.id,
required this.file,
required this.fileName,
required this.fileSize,
required this.targetPath,
DateTime? createdAt,
this.completedAt,
UploadStatus? status,
this.uploadedBytes = 0,
this.progress = 0,
this.uploadedChunks = 0,
this.totalChunks = 1,
this.errorMessage,
this.session,
this.speed = 0,
}) : createdAt = createdAt ?? DateTime.now(),
status = status ?? UploadStatus.waiting;
/// 计算总分片数
int calculateTotalChunks(int chunkSize) {
if (chunkSize <= 0) return 1;
return (fileSize / chunkSize).ceil();
}
/// 获取状态文本
String get statusText {
switch (status) {
case UploadStatus.waiting:
return '等待中';
case UploadStatus.uploading:
return '上传中...';
case UploadStatus.completed:
return '上传完成';
case UploadStatus.paused:
return '已暂停';
case UploadStatus.failed:
return '上传失败';
case UploadStatus.cancelled:
return '已取消';
}
}
/// 获取进度文本
String get progressText {
if (status == UploadStatus.completed) {
return '100%';
}
return '${(progress * 100).toStringAsFixed(1)}%';
}
/// 获取可读的文件大小
String get readableFileSize {
if (fileSize < 1024) {
return '$fileSize B';
} else if (fileSize < 1024 * 1024) {
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
} else if (fileSize < 1024 * 1024 * 1024) {
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
/// 获取可读的上传速度
String get speedText {
if (speed <= 0) return '';
if (speed < 1024) return '$speed B/s';
if (speed < 1024 * 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s';
return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
/// 克隆任务
UploadTaskModel copyWith({
UploadStatus? status,
int? uploadedBytes,
double? progress,
int? uploadedChunks,
int? totalChunks,
String? errorMessage,
UploadSessionModel? session,
DateTime? completedAt,
int? speed,
}) {
return UploadTaskModel(
id: id,
file: file,
fileName: fileName,
fileSize: fileSize,
targetPath: targetPath,
createdAt: createdAt,
completedAt: completedAt ?? this.completedAt,
status: status ?? this.status,
uploadedBytes: uploadedBytes ?? this.uploadedBytes,
progress: progress ?? this.progress,
uploadedChunks: uploadedChunks ?? this.uploadedChunks,
totalChunks: totalChunks ?? this.totalChunks,
errorMessage: errorMessage ?? this.errorMessage,
session: session ?? this.session,
speed: speed ?? this.speed,
);
}
/// 转换为 JSON(用于持久化)
Map<String, dynamic> toJson() {
return {
'id': id,
'file_path': file.path,
'file_name': fileName,
'file_size': fileSize,
'target_path': targetPath,
'created_at': createdAt.toIso8601String(),
'completed_at': completedAt?.toIso8601String(),
'status': status.index,
'uploaded_bytes': uploadedBytes,
'progress': progress,
'uploaded_chunks': uploadedChunks,
'total_chunks': totalChunks,
'error_message': errorMessage,
'speed': speed,
};
}
/// 从 JSON 创建(用于持久化恢复)
factory UploadTaskModel.fromJson(Map<String, dynamic> json) {
return UploadTaskModel(
id: json['id'] as String,
file: File(json['file_path'] as String),
fileName: json['file_name'] as String,
fileSize: json['file_size'] as int,
targetPath: json['target_path'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
completedAt: json['completed_at'] != null
? DateTime.parse(json['completed_at'] as String)
: null,
status: UploadStatus.values[json['status'] as int],
uploadedBytes: json['uploaded_bytes'] as int,
progress: json['progress'] as double,
uploadedChunks: json['uploaded_chunks'] as int,
totalChunks: json['total_chunks'] as int,
errorMessage: json['error_message'] as String?,
speed: json['speed'] as int? ?? 0,
);
}
}
+224
View File
@@ -0,0 +1,224 @@
/// 用户模型
class UserModel {
final String id;
final String? email;
final String nickname;
final String? avatar;
final DateTime createdAt;
final String? preferredTheme;
final String? language;
final bool? anonymous;
final GroupModel? group;
final List<PinedFileModel>? pined;
// Token 信息
final TokenModel? token;
UserModel({
required this.id,
this.email,
required this.nickname,
this.avatar,
required this.createdAt,
this.preferredTheme,
this.language,
this.anonymous,
this.group,
this.pined,
this.token,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
email: json['email'] as String?,
nickname: json['nickname'] as String,
avatar: json['avatar'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
preferredTheme: json['preferred_theme'] as String?,
language: json['language'] as String?,
anonymous: json['anonymous'] as bool?,
group: json['group'] != null
? GroupModel.fromJson(json['group'] as Map<String, dynamic>)
: null,
pined: json['pined'] != null
? (json['pined'] as List)
.map((e) => PinedFileModel.fromJson(e as Map<String, dynamic>))
.toList()
: null,
token: json['token'] != null
? TokenModel.fromJson(json['token'] as Map<String, dynamic>)
: null,
);
}
UserModel copyWith({
String? id,
String? email,
String? nickname,
String? avatar,
DateTime? createdAt,
String? preferredTheme,
String? language,
bool? anonymous,
GroupModel? group,
List<PinedFileModel>? pined,
TokenModel? token,
}) {
return UserModel(
id: id ?? this.id,
email: email ?? this.email,
nickname: nickname ?? this.nickname,
avatar: avatar ?? this.avatar,
createdAt: createdAt ?? this.createdAt,
preferredTheme: preferredTheme ?? this.preferredTheme,
language: language ?? this.language,
anonymous: anonymous ?? this.anonymous,
group: group ?? this.group,
pined: pined ?? this.pined,
token: token ?? this.token,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'nickname': nickname,
'avatar': avatar,
'created_at': createdAt.toIso8601String(),
'preferred_theme': preferredTheme,
'language': language,
'anonymous': anonymous,
'group': group?.toJson(),
'pined': pined?.map((e) => e.toJson()).toList(),
'token': token?.toJson(),
};
}
}
/// 用户组模型
class GroupModel {
final String id;
final String name;
final String? permission;
final int? directLinkBatchSize;
final int? trashRetention;
GroupModel({
required this.id,
required this.name,
this.permission,
this.directLinkBatchSize,
this.trashRetention,
});
factory GroupModel.fromJson(Map<String, dynamic> json) {
return GroupModel(
id: json['id'] as String,
name: json['name'] as String,
permission: json['permission'] as String?,
directLinkBatchSize: json['direct_link_batch_size'] as int?,
trashRetention: json['trash_retention'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'permission': permission,
'direct_link_batch_size': directLinkBatchSize,
'trash_retention': trashRetention,
};
}
}
/// 固定文件模型
class PinedFileModel {
final String uri;
final String? name;
PinedFileModel({required this.uri, this.name});
factory PinedFileModel.fromJson(Map<String, dynamic> json) {
return PinedFileModel(
uri: json['uri'] as String,
name: json['name'] as String?,
);
}
Map<String, dynamic> toJson() {
return {'uri': uri, 'name': name};
}
}
/// Token模型
class TokenModel {
final String accessToken;
final String refreshToken;
final DateTime accessExpires;
final DateTime refreshExpires;
TokenModel({
required this.accessToken,
required this.refreshToken,
required this.accessExpires,
required this.refreshExpires,
});
factory TokenModel.fromJson(Map<String, dynamic> json) {
// 支持两种格式:直接是 token 数据或嵌套在 data 中
Map<String, dynamic> data;
if (json['data'] != null) {
data = json['data'] as Map<String, dynamic>;
} else {
data = json;
}
return TokenModel(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String,
accessExpires: DateTime.parse(data['access_expires'] as String),
refreshExpires: DateTime.parse(data['refresh_expires'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'access_token': accessToken,
'refresh_token': refreshToken,
'access_expires': accessExpires.toIso8601String(),
'refresh_expires': refreshExpires.toIso8601String(),
};
}
/// 检查 access token 是否过期
bool get isAccessTokenExpired => DateTime.now().isAfter(accessExpires);
/// 检查 refresh token 是否过期
bool get isRefreshTokenExpired => DateTime.now().isAfter(refreshExpires);
}
/// 用户容量模型
class CapacityModel {
final int total;
final int used;
double get usagePercentage => total > 0 ? (used / total) * 100 : 0;
int get remaining => total - used;
CapacityModel({required this.total, required this.used});
factory CapacityModel.fromJson(Map<String, dynamic> json) {
return CapacityModel(
total: json['total'] as int,
used: json['used'] as int,
);
}
Map<String, dynamic> toJson() {
return {'total': total, 'used': used};
}
}
+346
View File
@@ -0,0 +1,346 @@
/// 用户设置模型 - 对应 GET /user/setting 响应
class UserSettingModel {
final DateTime? groupExpires;
final List<OpenIdProvider> openId;
final bool versionRetentionEnabled;
final List<String>? versionRetentionExt;
final int versionRetentionMax;
final bool passwordless;
final bool twoFaEnabled;
final List<PasskeyModel> passkeys;
final List<LoginActivity> loginActivity;
final List<StoragePack> storagePacks;
final int credit;
final bool disableViewSync;
final String shareLinksInProfile;
final List<OAuthGrant> oauthGrants;
UserSettingModel({
this.groupExpires,
this.openId = const [],
this.versionRetentionEnabled = false,
this.versionRetentionExt,
this.versionRetentionMax = 0,
this.passwordless = false,
this.twoFaEnabled = false,
this.passkeys = const [],
this.loginActivity = const [],
this.storagePacks = const [],
this.credit = 0,
this.disableViewSync = false,
this.shareLinksInProfile = '',
this.oauthGrants = const [],
});
factory UserSettingModel.fromJson(Map<String, dynamic> json) {
return UserSettingModel(
groupExpires: json['group_expires'] != null
? DateTime.parse(json['group_expires'] as String)
: null,
openId: (json['open_id'] as List?)
?.map((e) => OpenIdProvider.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
versionRetentionEnabled: json['version_retention_enabled'] as bool? ?? false,
versionRetentionExt: (json['version_retention_ext'] as List?)
?.map((e) => e as String)
.toList(),
versionRetentionMax: json['version_retention_max'] as int? ?? 0,
passwordless: json['passwordless'] as bool? ?? false,
twoFaEnabled: json['two_fa_enabled'] as bool? ?? false,
passkeys: (json['passkeys'] as List?)
?.map((e) => PasskeyModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
loginActivity: (json['login_activity'] as List?)
?.map((e) => LoginActivity.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
storagePacks: (json['storage_packs'] as List?)
?.map((e) => StoragePack.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
credit: json['credit'] as int? ?? 0,
disableViewSync: json['disable_view_sync'] as bool? ?? false,
shareLinksInProfile: json['share_links_in_profile'] as String? ?? '',
oauthGrants: (json['oauth_grants'] as List?)
?.map((e) => OAuthGrant.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
UserSettingModel copyWith({
DateTime? groupExpires,
List<OpenIdProvider>? openId,
bool? versionRetentionEnabled,
List<String>? versionRetentionExt,
int? versionRetentionMax,
bool? passwordless,
bool? twoFaEnabled,
List<PasskeyModel>? passkeys,
List<LoginActivity>? loginActivity,
List<StoragePack>? storagePacks,
int? credit,
bool? disableViewSync,
String? shareLinksInProfile,
List<OAuthGrant>? oauthGrants,
}) {
return UserSettingModel(
groupExpires: groupExpires ?? this.groupExpires,
openId: openId ?? this.openId,
versionRetentionEnabled: versionRetentionEnabled ?? this.versionRetentionEnabled,
versionRetentionExt: versionRetentionExt ?? this.versionRetentionExt,
versionRetentionMax: versionRetentionMax ?? this.versionRetentionMax,
passwordless: passwordless ?? this.passwordless,
twoFaEnabled: twoFaEnabled ?? this.twoFaEnabled,
passkeys: passkeys ?? this.passkeys,
loginActivity: loginActivity ?? this.loginActivity,
storagePacks: storagePacks ?? this.storagePacks,
credit: credit ?? this.credit,
disableViewSync: disableViewSync ?? this.disableViewSync,
shareLinksInProfile: shareLinksInProfile ?? this.shareLinksInProfile,
oauthGrants: oauthGrants ?? this.oauthGrants,
);
}
}
/// 已关联的外部身份提供商
class OpenIdProvider {
final int provider;
final DateTime linkedAt;
OpenIdProvider({required this.provider, required this.linkedAt});
factory OpenIdProvider.fromJson(Map<String, dynamic> json) {
return OpenIdProvider(
provider: json['provider'] as int,
linkedAt: DateTime.parse(json['linked_at'] as String),
);
}
String get providerName {
switch (provider) {
case 0:
return 'Logto';
case 1:
return 'QQ';
case 2:
return 'OIDC';
default:
return 'Unknown';
}
}
}
/// Passkey 模型
class PasskeyModel {
final String id;
final String name;
final DateTime? usedAt;
final DateTime createdAt;
PasskeyModel({
required this.id,
required this.name,
this.usedAt,
required this.createdAt,
});
factory PasskeyModel.fromJson(Map<String, dynamic> json) {
return PasskeyModel(
id: json['id'] as String,
name: json['name'] as String,
usedAt: json['used_at'] != null
? DateTime.parse(json['used_at'] as String)
: null,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
/// 登录活动记录
class LoginActivity {
final DateTime createdAt;
final String ip;
final String browser;
final String device;
final String os;
final String loginWith;
final int? openIdProvider;
final bool success;
final bool webdav;
LoginActivity({
required this.createdAt,
required this.ip,
required this.browser,
required this.device,
required this.os,
required this.loginWith,
this.openIdProvider,
required this.success,
required this.webdav,
});
factory LoginActivity.fromJson(Map<String, dynamic> json) {
return LoginActivity(
createdAt: DateTime.parse(json['created_at'] as String),
ip: json['ip'] as String,
browser: json['browser'] as String,
device: json['device'] as String,
os: json['os'] as String,
loginWith: json['login_with'] as String? ?? '',
openIdProvider: json['open_id_provider'] as int?,
success: json['success'] as bool? ?? false,
webdav: json['webdav'] as bool? ?? false,
);
}
String get loginMethodName {
if (webdav) return 'WebDAV';
switch (loginWith) {
case 'passkey':
return 'Passkey';
case 'openid':
return '第三方登录';
default:
return '密码';
}
}
}
/// 存储包
class StoragePack {
final String name;
final DateTime activeSince;
final DateTime? expireAt;
final int size;
StoragePack({
required this.name,
required this.activeSince,
this.expireAt,
required this.size,
});
factory StoragePack.fromJson(Map<String, dynamic> json) {
return StoragePack(
name: json['name'] as String,
activeSince: DateTime.parse(json['active_since'] as String),
expireAt: json['expire_at'] != null
? DateTime.parse(json['expire_at'] as String)
: null,
size: json['size'] as int,
);
}
bool get isExpired => expireAt != null && DateTime.now().isAfter(expireAt!);
}
/// OAuth 授权应用
class OAuthGrant {
final String clientId;
final String clientName;
final String? clientLogo;
final List<String> scopes;
final DateTime? lastUsedAt;
OAuthGrant({
required this.clientId,
required this.clientName,
this.clientLogo,
this.scopes = const [],
this.lastUsedAt,
});
factory OAuthGrant.fromJson(Map<String, dynamic> json) {
return OAuthGrant(
clientId: json['client_id'] as String,
clientName: json['client_name'] as String,
clientLogo: json['client_logo'] as String?,
scopes: (json['scopes'] as List?)?.map((e) => e as String).toList() ?? [],
lastUsedAt: json['last_used_at'] != null
? DateTime.parse(json['last_used_at'] as String)
: null,
);
}
}
/// 用户容量模型(扩展版,含存储包)
class UserCapacityModel {
final int total;
final int used;
final int storagePackTotal;
UserCapacityModel({
required this.total,
required this.used,
this.storagePackTotal = 0,
});
factory UserCapacityModel.fromJson(Map<String, dynamic> json) {
return UserCapacityModel(
total: json['total'] as int,
used: json['used'] as int,
storagePackTotal: json['storage_pack_total'] as int? ?? 0,
);
}
double get usagePercentage => total > 0 ? (used / total) * 100 : 0;
int get remaining => total - used;
}
/// 积分变动记录
class CreditChange {
final DateTime changedAt;
final int diff;
final String reason;
CreditChange({
required this.changedAt,
required this.diff,
required this.reason,
});
factory CreditChange.fromJson(Map<String, dynamic> json) {
return CreditChange(
changedAt: DateTime.parse(json['changed_at'] as String),
diff: json['diff'] as int,
reason: json['reason'] as String,
);
}
String get reasonLabel {
switch (reason) {
case 'share_purchased':
return '分享被购买';
case 'pay':
return '支付';
case 'adjust':
return '管理员调整';
default:
return reason;
}
}
}
/// 积分变动列表响应
class CreditChangeList {
final List<CreditChange> changes;
final String? nextToken;
CreditChangeList({required this.changes, this.nextToken});
factory CreditChangeList.fromJson(Map<String, dynamic> json) {
return CreditChangeList(
changes: (json['changes'] as List?)
?.map((e) => CreditChange.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
nextToken: (json['pagination'] as Map<String, dynamic>?)?['next_token'] as String?,
);
}
bool get hasMore => nextToken != null && nextToken!.isNotEmpty;
}
+263
View File
@@ -0,0 +1,263 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_single_instance/flutter_single_instance.dart';
import 'package:provider/provider.dart';
import 'package:media_kit/media_kit.dart';
import 'package:oktoast/oktoast.dart';
import 'package:window_manager/window_manager.dart';
import 'config/app_config.dart';
import 'presentation/providers/auth_provider.dart';
import 'presentation/providers/file_manager_provider.dart';
import 'presentation/providers/navigation_provider.dart';
import 'presentation/providers/upload_manager_provider.dart';
import 'presentation/providers/download_manager_provider.dart';
import 'presentation/providers/user_setting_provider.dart';
import 'presentation/providers/admin_provider.dart';
import 'presentation/providers/theme_provider.dart';
import 'services/upload_service.dart';
import 'services/api_service.dart';
import 'services/server_service.dart';
import 'services/cache_manager_service.dart';
import 'services/avatar_cache_service.dart';
import 'core/utils/video_fullscreen.dart';
import 'services/desktop_service.dart';
import 'router/app_router.dart';
import 'presentation/widgets/toast_helper.dart';
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常
// 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略
FlutterError.onError = (details) {
final msg = details.exceptionAsString();
if (msg.contains('PathAccessException') || msg.contains('errno = 32')) {
return; // Windows 文件占用,忽略
}
FlutterError.presentError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
if (error is PathAccessException) {
return true; // 已处理,不传播
}
return false;
};
// 初始化日志
await AppLogger.init();
AppLogger.i("应用启动,日志系统就绪");
// 桌面端初始化窗口管理和系统托盘
if (Platform.isWindows || Platform.isLinux) {
// 实例化 FlutterSingleInstance 获取单实例句柄
final singleInstance = FlutterSingleInstance();
final addr = FlutterSingleInstance.address as InternetAddress;
// 检查是否是第一个实例
final bool isFirstInstance = await singleInstance.isFirstInstance();
if (!isFirstInstance) {
AppLogger.i("程序已经在运行, 尝试唤醒...");
// 如果已经有实例在运行,通过focus回调发送消息给"第一个已启动的实例"
// 第一个实例的 listen 会收到一个字符串
await singleInstance.focus({"action": "bring_to_front_showWindow"});
// 退出当前新启动的进程
exit(0);
}
final String processName = await singleInstance.getProcessName(pid) ?? "Unknown";
final File? pidFile = await singleInstance.getPidFile(processName);
int port = FlutterSingleInstance.port;
if (await pidFile!.exists()) {
try {
final content = await pidFile.readAsString();
final Map<String, dynamic> data = jsonDecode(content);
port = data['port'] ?? 0;
} catch (e) {
AppLogger.e("Get FlutterSingleInstance port has error: ${e.toString()}");
}
}
AppLogger.i("processName: $processName \npid: $pid \npidFile: ${pidFile.path.toString()} \nSingleInstance RPC address:port: ${addr.address}:$port");
FlutterSingleInstance.onFocus = (metadata) async {
AppLogger.i("收到唤醒信号: $metadata");
await DesktopService.instance.showWindow();
};
await DesktopService.instance.initialize();
}
// 初始化MediaKit
MediaKit.ensureInitialized();
// 初始化服务器服务
await ServerService.instance.init();
// 初始化API服务
await ApiService.instance.init();
// 初始化缓存管理器
await CacheManagerService.instance.initialize();
// 初始化头像缓存服务
await AvatarCacheService.instance.init();
// 设置横竖屏方向(仅移动端)
if (Platform.isAndroid || Platform.isIOS) {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
// 设置状态栏样式
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
),
);
runApp(const CloudreveApp());
}
class CloudreveApp extends StatelessWidget {
const CloudreveApp({super.key});
@override
Widget build(BuildContext context) {
return
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ThemeProvider()..init()),
ChangeNotifierProvider(create: (_) => AuthProvider()..init()),
ChangeNotifierProvider(create: (_) => FileManagerProvider()),
ChangeNotifierProvider(create: (_) => NavigationProvider()),
ChangeNotifierProvider(create: (_) => UploadService()),
ChangeNotifierProvider(create: (_) => UploadManagerProvider()..initialize()),
ChangeNotifierProvider(create: (_) => DownloadManagerProvider()..initialize()),
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
ChangeNotifierProvider(create: (_) => AdminProvider()),
],
child: const AppView(),
);
}
}
class AppView extends StatelessWidget {
const AppView({super.key});
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final flutterThemeMode = switch (themeProvider.themeMode) {
AppThemeMode.light => ThemeMode.light,
AppThemeMode.dark => ThemeMode.dark,
AppThemeMode.system => ThemeMode.system,
};
return OKToast(
child: MaterialApp(
title: AppConfig.appName,
debugShowCheckedModeBanner: false,
theme: themeProvider.buildLightTheme(),
darkTheme: themeProvider.buildDarkTheme(),
themeMode: flutterThemeMode,
onGenerateRoute: AppRouter.generateRoute,
initialRoute: RouteNames.splash,
navigatorObservers: [routeObserver],
builder: (context, child) {
if (child == null) return const SizedBox.shrink();
Widget currentWidget = child;
if (Platform.isWindows || Platform.isLinux) {
currentWidget = Material(
color: themeProvider.isDark ? Colors.black.withValues(alpha: 0.92) : Colors.white.withValues(alpha: 0.92),
child: ValueListenableBuilder<bool>(
valueListenable: videoFullscreenNotifier,
builder: (context, isVideoFullscreen, child) {
if (isVideoFullscreen) {
return child!;
}
return Column(
children: [
const SizedBox(
height: 32,
child: DragToMoveArea(
child: DesktopTitleBar(),
),
),
Expanded(
child: child!,
),
],
);
},
child: currentWidget,
),
);
// 添加全局错误处理
currentWidget = FilterQualityWidget(child: currentWidget);
}
// 添加全局错误处理
return ErrorHandler(child: currentWidget);
},
),
);
}
}
// 定义一个简单的包装类,确保子组件在绘制时使用高画质滤镜走抗锯齿逻辑
// ImageFiltered 会强制渲染引擎进行重绘计算,解决 Windows 上部分 UI 边缘生硬的问题
class FilterQualityWidget extends StatelessWidget {
final Widget child;
const FilterQualityWidget({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ImageFiltered(
// 在 Windows 上,此滤镜能强制 Skia 引擎重新计算像素边缘
imageFilter: ColorFilter.mode(Colors.transparent, BlendMode.multiply),
child: child,
);
}
}
/// 全局错误处理器
class ErrorHandler extends StatelessWidget {
final Widget child;
const ErrorHandler({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, authProvider, child) {
// 检查是否有待处理的登录过期错误
if (authProvider.hasRefreshTokenExpired) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final navigator = Navigator.of(context);
ToastHelper.failure('登录已过期,请重新登录');
navigator.pushNamedAndRemoveUntil(
RouteNames.login,
(route) => false,
);
authProvider.clearRefreshTokenExpired();
});
}
return child!;
},
child: child,
);
}
}
@@ -0,0 +1,139 @@
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../core/validators/string_validator.dart';
import '../../../services/auth_service.dart';
import '../../widgets/toast_helper.dart';
class ForgotPasswordPage extends StatefulWidget {
const ForgotPasswordPage({super.key});
@override
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _sendResetEmail() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
await AuthService.instance.sendResetPasswordEmail(
email: _emailController.text.trim(),
);
if (mounted) {
ToastHelper.success('重置密码邮件已发送,请查收邮箱');
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
setState(() => _errorMessage = e.toString());
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('忘记密码')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: DesktopConstrained(
maxContentWidth: 480,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'请输入您的邮箱地址,我们将向您发送重置密码的邮件。',
style: TextStyle(
fontSize: 14,
color: theme.hintColor,
),
),
const SizedBox(height: 24),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
validator: StringValidator.validateEmail,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: '请输入邮箱地址',
prefixIcon: Icon(LucideIcons.mail),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
fontSize: 13,
),
),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _sendResetEmail,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('发送重置邮件'),
),
],
),
),
),
),
),
),
),
),
);
}
}
+522
View File
@@ -0,0 +1,522 @@
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/exceptions/app_exception.dart';
import '../../../core/validators/string_validator.dart';
import '../../providers/auth_provider.dart';
import '../../../services/server_service.dart';
import '../../../router/app_router.dart';
import 'forgot_password_page.dart';
import 'register_page.dart';
import '../../widgets/toast_helper.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _focusNode = FocusNode();
bool _obscurePassword = true;
bool _rememberMe = false;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadRememberedInfo();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _loadRememberedInfo() async {
final server = ServerService.instance.currentServer;
if (server != null) {
setState(() {
if (server.email != null) {
_emailController.text = server.email!;
}
if (server.password != null && server.rememberMe) {
_passwordController.text = server.password!;
}
_rememberMe = server.rememberMe;
});
}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final navigator = Navigator.of(context);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
setState(() => _isLoading = true);
try {
final success = await authProvider.passwordLogin(
email: _emailController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
).timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('请求超时'),
);
if (mounted) setState(() => _isLoading = false);
if (success && mounted) {
_focusNode.unfocus();
ToastHelper.success('登录成功');
await Future.delayed(const Duration(seconds: 1));
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
} else if (mounted) {
final errorMessage = authProvider.errorMessage;
if (errorMessage != null && errorMessage.isNotEmpty) {
final errorMsg = _parseErrorMessage(errorMessage);
ToastHelper.failure(errorMsg);
} else {
ToastHelper.failure('登录失败');
}
}
} on TwoFactorRequiredException catch (e) {
if (mounted) {
setState(() => _isLoading = false);
_showTwoFactorDialog(e.sessionId);
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
final errorMsg = _parseErrorMessage(e.toString());
ToastHelper.failure(errorMsg);
}
}
}
Future<void> _showTwoFactorDialog(String sessionId) async {
final success = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => _TwoFactorDialog(
sessionId: sessionId,
email: _emailController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
),
);
if (success != true || !mounted) return;
_focusNode.unfocus();
ToastHelper.success('登录成功');
await Future.delayed(const Duration(seconds: 1));
if (mounted) Navigator.of(context).pushReplacementNamed(RouteNames.home);
}
String _parseErrorMessage(String error) {
if (error.startsWith('Exception(') || error.startsWith('AppException(')) {
final startIdx = error.indexOf('(');
final endIdx = error.lastIndexOf(')');
if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
return error.substring(startIdx + 1, endIdx).trim();
}
}
if (error.contains(':')) {
final parts = error.split(':');
if (parts.length > 1) {
final msg = parts.sublist(1).join(':').trim();
if (msg.isNotEmpty) return '登录失败: $msg';
}
}
if (error.contains('"') && error.split('"').length >= 2) {
final parts = error.split('"');
if (parts.length >= 2) {
final msg = parts[1].trim();
if (msg.isNotEmpty && msg != 'login') return '登录失败: $msg';
}
}
return error.isEmpty ? '登录失败: 未知原因' : '登录失败: $error';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: DesktopConstrained(
maxContentWidth: 480,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(child: _buildLogo()),
const SizedBox(height: 32),
Center(
child: Text(
'公云存储',
style: theme.textTheme.headlineMedium,
),
),
const SizedBox(height: 32),
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 邮箱
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: StringValidator.validateEmail,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: '请输入邮箱地址',
prefixIcon: Icon(LucideIcons.mail),
),
onFieldSubmitted: (_) => _focusNode.requestFocus(),
),
const SizedBox(height: 16),
// 密码
TextFormField(
controller: _passwordController,
focusNode: _focusNode,
obscureText: _obscurePassword,
validator: StringValidator.validatePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: const Icon(LucideIcons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? LucideIcons.eye
: LucideIcons.eyeOff,
size: 20,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
onFieldSubmitted: (_) => _login(),
),
const SizedBox(height: 12),
// 记住我
InkWell(
onTap: () => setState(() => _rememberMe = !_rememberMe),
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: _rememberMe,
onChanged: (v) => setState(() => _rememberMe = v ?? false),
),
),
const SizedBox(width: 8),
const Text('记住我'),
],
),
),
const SizedBox(height: 20),
// 链接按钮
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ForgotPasswordPage(),
),
);
},
child: const Text('忘记密码?'),
),
TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const RegisterPage(),
),
);
},
child: const Text('注册账号'),
),
],
),
const SizedBox(height: 16),
// 登录按钮
FilledButton(
onPressed: _isLoading ? null : _login,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
],
),
),
),
),
],
),
),
),
),
),
);
}
Widget _buildLogo() {
return ClipOval(
child: Image.asset(
'assets/images/app_logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
);
}
}
/// 两步验证输入对话框
class _TwoFactorDialog extends StatefulWidget {
final String sessionId;
final String email;
final String password;
final bool rememberMe;
const _TwoFactorDialog({
required this.sessionId,
required this.email,
required this.password,
required this.rememberMe,
});
@override
State<_TwoFactorDialog> createState() => _TwoFactorDialogState();
}
class _TwoFactorDialogState extends State<_TwoFactorDialog>
with SingleTickerProviderStateMixin {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _isSubmitting = false;
late final AnimationController _shakeController;
late final Animation<double> _shakeAnimation;
@override
void initState() {
super.initState();
_shakeController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_shakeAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
]).animate(CurvedAnimation(
parent: _shakeController,
curve: Curves.easeInOut,
));
_controller.addListener(_onTextChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
_shakeController.dispose();
super.dispose();
}
void _onTextChanged() {
if (_controller.text.length == 6 && !_isSubmitting) {
_submit();
}
}
Future<void> _submit() async {
final code = _controller.text.trim();
if (code.length != 6 || int.tryParse(code) == null) return;
setState(() => _isSubmitting = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
try {
final success = await authProvider.twoFactorLogin(
otp: code,
sessionId: widget.sessionId,
email: widget.email,
password: widget.password,
rememberMe: widget.rememberMe,
).timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('请求超时'),
);
if (!mounted) return;
if (success) {
Navigator.of(context).pop(true);
} else {
_onVerifyFailed(authProvider.errorMessage ?? '验证码错误');
}
} catch (e) {
if (mounted) _onVerifyFailed(_parse2FAError(e.toString()));
}
}
void _onVerifyFailed(String message) {
setState(() => _isSubmitting = false);
_controller.clear();
_focusNode.requestFocus();
_shakeController.forward(from: 0);
ToastHelper.failure(message);
}
String _parse2FAError(String error) {
if (error.startsWith('Exception(') || error.startsWith('AppException(')) {
final startIdx = error.indexOf('(');
final endIdx = error.lastIndexOf(')');
if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
return error.substring(startIdx + 1, endIdx).trim();
}
}
return error.isEmpty ? '验证码错误' : error;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Row(
children: [
Icon(LucideIcons.shieldCheck, color: theme.colorScheme.primary),
const SizedBox(width: 8),
const Text('两步验证'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'请输入身份验证器中的6位数字验证码',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value, 0),
child: child,
);
},
child: TextField(
controller: _controller,
focusNode: _focusNode,
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
enabled: !_isSubmitting,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
letterSpacing: 8,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
letterSpacing: 8,
color: theme.colorScheme.outline.withValues(alpha: 0.4),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onSubmitted: (_) => _submit(),
),
),
],
),
actions: [
TextButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('验证'),
),
],
);
}
}
@@ -0,0 +1,191 @@
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../core/validators/string_validator.dart';
import '../../../services/auth_service.dart';
import '../../widgets/toast_helper.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _register() async {
if (!_formKey.currentState!.validate()) return;
if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _errorMessage = '两次输入的密码不一致');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await AuthService.instance.signUp(
email: _emailController.text.trim(),
password: _passwordController.text,
language: 'zh-CN',
);
if (mounted) {
ToastHelper.success(
response.requiresEmailActivation ? '注册成功,请查收邮箱进行验证' : '注册成功',
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
setState(() => _errorMessage = e.toString());
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('注册')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: DesktopConstrained(
maxContentWidth: 480,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
validator: StringValidator.validateEmail,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: '请输入邮箱地址',
prefixIcon: Icon(LucideIcons.mail),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
validator: StringValidator.validatePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '请输入密码(至少6位)',
prefixIcon: const Icon(LucideIcons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? LucideIcons.eye : LucideIcons.eyeOff,
size: 20,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
validator: (value) {
if (value == null || value.isEmpty) return '请确认密码';
if (value != _passwordController.text) return '两次输入的密码不一致';
return null;
},
decoration: InputDecoration(
labelText: '确认密码',
hintText: '请再次输入密码',
prefixIcon: const Icon(LucideIcons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? LucideIcons.eye : LucideIcons.eyeOff,
size: 20,
),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
fontSize: 13,
),
),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _register,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('注册'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('已有账号?去登录'),
),
],
),
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,938 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:cloudreve4_flutter/services/upload_service.dart';
import '../../../core/utils/file_utils.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/file_manager_provider.dart';
import '../../providers/download_manager_provider.dart';
import '../../providers/upload_manager_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../widgets/file_list_item.dart';
import '../../widgets/file_grid_item.dart';
import '../../widgets/file_list_header.dart';
import '../../widgets/file_breadcrumb.dart';
import '../../widgets/selection_toolbar.dart';
import '../../widgets/empty_folder_view.dart';
import '../../widgets/upload_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/search_dialog.dart';
import '../../widgets/toast_helper.dart';
import '../../../router/app_router.dart';
import '../../../core/utils/file_type_utils.dart';
class FilesPage extends StatefulWidget {
const FilesPage({super.key});
@override
State<FilesPage> createState() => _FilesPageState();
}
class _FilesPageState extends State<FilesPage> {
bool _isFirstLoad = true;
FileModel? _infoFile;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
// FAB 状态
bool _isFabVisible = true;
bool _isFabExpanded = false;
Timer? _fabShowTimer;
// 桌面端拖拽状态
bool _isDraggingOver = false;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid);
} else {
fileManager.setViewType(FileViewType.list);
}
if (_isFirstLoad) {
fileManager.loadFiles();
_isFirstLoad = false;
}
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
downloadManager.initialize();
}
});
// 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName'
: '$targetPath/$fileName';
fileManager.addFileByUri(fileUri);
}
};
}
@override
void dispose() {
_fabShowTimer?.cancel();
UploadService.instance.onUploadCompleted = null;
super.dispose();
}
void _showFileInfo(FileModel file) {
setState(() => _infoFile = file);
// 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开
WidgetsBinding.instance.addPostFrameCallback((_) {
_scaffoldKey.currentState?.openEndDrawer();
});
}
// ---- FAB 显隐控制 ----
void _hideFab() {
_fabShowTimer?.cancel();
if (_isFabVisible) {
setState(() {
_isFabVisible = false;
_isFabExpanded = false;
});
}
}
void _scheduleShowFab() {
_fabShowTimer?.cancel();
_fabShowTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isFabVisible) {
setState(() => _isFabVisible = true);
}
});
}
bool _onScrollNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification ||
notification is ScrollUpdateNotification) {
_hideFab();
} else if (notification is ScrollEndNotification) {
_scheduleShowFab();
}
return false;
}
void _toggleFabExpanded() {
setState(() => _isFabExpanded = !_isFabExpanded);
}
void _onFabSubAction(VoidCallback action) {
setState(() => _isFabExpanded = false);
action();
}
// ---- 构建方法 ----
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
return Scaffold(
key: _scaffoldKey,
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
return AppBar(
title: Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (isDesktop) {
if (fileManager.currentPath == '/') {
return const Text('文件');
}
final segments = fileManager.currentPath.split('/').where((s) => s.isNotEmpty).toList();
return Text(segments.isNotEmpty ? segments.last : '文件');
}
return _buildMobileBreadcrumb(context, fileManager);
},
),
actions: isDesktop ? _buildDesktopActions() : _buildMobileActions(),
);
}
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_buildBreadcrumbChip(
context,
label: '文件',
icon: LucideIcons.home,
color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
),
_buildBreadcrumbChip(
context,
label: pathParts[i],
icon: null,
color: colorScheme.primary,
isLast: i == pathParts.length - 1,
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
},
),
],
],
),
);
}
Widget _buildBreadcrumbChip(
BuildContext context, {
required String label,
required IconData? icon,
required Color color,
bool isLast = false,
VoidCallback? onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 3),
],
Text(
label,
style: TextStyle(
color: color,
fontWeight: isLast ? FontWeight.w600 : FontWeight.w500,
fontSize: 12,
),
),
],
),
),
);
}
List<Widget> _buildDesktopActions() {
return [
IconButton(
icon: const Icon(LucideIcons.search),
onPressed: () => SearchDialog.show(context),
tooltip: '搜索',
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return IconButton(
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新',
);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
? Icons.grid_view
: Icons.view_list;
return IconButton(
icon: Icon(icon),
onPressed: () {
fileManager.setViewType(
fileManager.viewType == FileViewType.list
? FileViewType.grid
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
FileOperationDialogs.showCreateDialog(context, fileManager);
},
tooltip: '新建',
),
IconButton(
icon: const Icon(Icons.cloud_upload),
onPressed: () => showUploadDialog(context),
tooltip: '上传',
),
IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载',
),
];
}
List<Widget> _buildMobileActions() {
return [
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
? Icons.grid_view
: Icons.view_list;
return IconButton(
icon: Icon(icon),
onPressed: () {
fileManager.setViewType(
fileManager.viewType == FileViewType.list
? FileViewType.grid
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
);
},
),
];
}
// ---- SpeedDial FAB ----
Widget _buildSpeedDialFAB(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return AnimatedSlide(
offset: _isFabVisible ? Offset.zero : const Offset(0, 2),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: AnimatedOpacity(
opacity: _isFabVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildFabSubItem(
context: context,
index: 0,
icon: LucideIcons.search,
label: '搜索',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => SearchDialog.show(context)),
),
_buildFabSubItem(
context: context,
index: 1,
icon: LucideIcons.upload,
label: '上传',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => showUploadDialog(context)),
),
_buildFabSubItem(
context: context,
index: 2,
icon: LucideIcons.folderPlus,
label: '新建文件夹',
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
},
),
_buildFabSubItem(
context: context,
index: 3,
icon: LucideIcons.download,
label: '离线下载',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, _) {
final isListView = fileManager.viewType == FileViewType.list;
return _buildFabSubItem(
context: context,
index: 4,
icon: isListView ? LucideIcons.layoutGrid : LucideIcons.list,
label: isListView ? '网格视图' : '列表视图',
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
_onFabSubAction(() {
fileManager.setViewType(
isListView ? FileViewType.grid : FileViewType.list,
);
});
},
);
},
),
// 主按钮:与子按钮同风格同尺寸
Padding(
padding: const EdgeInsets.only(bottom: 4, right: 4),
child: AnimatedScale(
scale: _isFabExpanded ? 1.0 : 1.08,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _buildFabButton(
isDark: isDark,
colorScheme: colorScheme,
onTap: _toggleFabExpanded,
child: AnimatedRotation(
turns: _isFabExpanded ? 0.125 : 0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Icon(
LucideIcons.plus,
color: colorScheme.primary,
size: 22,
),
),
),
),
),
],
),
),
);
}
Widget _buildFabSubItem({
required BuildContext context,
required int index,
required IconData icon,
required String label,
required bool isDark,
required ColorScheme colorScheme,
required VoidCallback onTap,
}) {
final staggerDelay = Duration(milliseconds: 50 * index);
return AnimatedSlide(
offset: _isFabExpanded ? Offset.zero : const Offset(0, 1.2),
duration: const Duration(milliseconds: 250) + staggerDelay,
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: _isFabExpanded ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200) + staggerDelay,
curve: Curves.easeOut,
child: AnimatedScale(
scale: _isFabExpanded ? 1.0 : 0.4,
duration: const Duration(milliseconds: 250) + staggerDelay,
curve: Curves.easeOutCubic,
child: Padding(
padding: const EdgeInsets.only(bottom: 14, right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.4),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.grey.shade800,
),
),
),
),
),
const SizedBox(width: 12),
_buildFabButton(
isDark: isDark,
colorScheme: colorScheme,
onTap: onTap,
child: Icon(icon, size: 20, color: colorScheme.primary),
),
],
),
),
),
),
);
}
/// 统一的毛玻璃圆形按钮
Widget _buildFabButton({
required bool isDark,
required ColorScheme colorScheme,
required VoidCallback onTap,
required Widget child,
}) {
const size = 44.0;
const radius = 22.0;
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: colorScheme.primary.withValues(alpha: isDark ? 0.25 : 0.2),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: onTap,
child: Center(child: child),
),
),
),
),
);
}
// ---- Body ----
Widget _buildBody(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context);
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child;
}
return DropTarget(
onDragEntered: (_) => setState(() => _isDraggingOver = true),
onDragExited: (_) => setState(() => _isDraggingOver = false),
onDragDone: (details) {
setState(() => _isDraggingOver = false);
_handleDroppedFiles(details.files);
},
child: Stack(
children: [
child,
if (_isDraggingOver)
IgnorePointer(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(
'释放文件以上传到当前目录',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
],
),
);
}
void _handleDroppedFiles(List<XFile> droppedFiles) {
final files = <File>[];
for (final xFile in droppedFiles) {
final path = xFile.path;
if (path.isNotEmpty) {
files.add(File(path));
}
}
if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
}
Widget _buildFileList(BuildContext context) {
return Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (fileManager.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (fileManager.errorMessage != null) {
return _buildErrorView(context, fileManager);
}
if (fileManager.files.isEmpty) {
return EmptyFolderView(currentPath: fileManager.currentPath);
}
if (fileManager.viewType == FileViewType.list) {
return _buildListView(context, fileManager);
}
return _buildGridView(context, fileManager);
},
);
}
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
fileManager.errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).hintColor),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => fileManager.loadFiles(),
child: const Text('重试'),
),
],
),
);
}
Future<void> _onRefresh(FileManagerProvider fileManager) async {
final result = await fileManager.refreshFiles();
if (!mounted) return;
if (fileManager.errorMessage != null) {
ToastHelper.error(fileManager.errorMessage!);
return;
}
if (result.isUnchanged) {
ToastHelper.info('列表已是最新');
} else {
final parts = <String>[];
if (result.added > 0) parts.add('新增 ${result.added}');
if (result.removed > 0) parts.add('移除 ${result.removed}');
if (result.updated > 0) parts.add('更新 ${result.updated}');
ToastHelper.success('已刷新:${parts.join('')}');
}
}
Widget _buildListView(BuildContext context, FileManagerProvider fileManager) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final showCheckbox = fileManager.hasSelection;
return Column(
children: [
if (isDesktop) FileListHeader(showCheckbox: showCheckbox),
Expanded(
child: RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: ListView.builder(
itemCount: fileManager.files.length,
itemBuilder: (context, index) {
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path);
return FileListItem(
key: ValueKey('file_${file.id}'),
file: file,
isSelected: isSelected,
isHighlighted: file.path == fileManager.highlightPath,
showCheckbox: showCheckbox,
index: index,
isDesktop: isDesktop,
onTap: () {
_hideFab();
_scheduleShowFab();
if (showCheckbox) {
fileManager.toggleSelection(file.path);
} else if (file.isFolder) {
fileManager.enterFolder(file.relativePath);
} else {
_openFile(context, file);
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onInfo: () => _showFileInfo(file),
);
},
),
),
),
),
],
);
}
Widget _buildGridView(BuildContext context, FileManagerProvider fileManager) {
final screenWidth = MediaQuery.sizeOf(context).width;
final padding = 16.0;
final spacing = 16.0;
final availableWidth = screenWidth - padding * 2;
int crossAxisCount;
if (screenWidth < 400) {
crossAxisCount = 2;
} else if (screenWidth < 600) {
crossAxisCount = 3;
} else if (screenWidth < 900) {
crossAxisCount = 4;
} else {
crossAxisCount = 5;
}
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection;
return RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: spacing / 2,
crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio,
),
itemCount: fileManager.files.length,
itemBuilder: (context, index) {
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path);
return FileGridItem(
key: ValueKey('file_grid_${file.id}'),
file: file,
isSelected: isSelected,
isHighlighted: file.path == fileManager.highlightPath,
showCheckbox: showCheckbox,
contextHint: fileManager.contextHint,
onTap: () {
_hideFab();
_scheduleShowFab();
if (showCheckbox) {
fileManager.toggleSelection(file.path);
} else if (file.isFolder) {
fileManager.enterFolder(file.relativePath);
} else {
_openFile(context, file);
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onInfo: () => _showFileInfo(file),
);
},
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
return Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
if (fileManager.hasSelection) {
return SelectionToolbar(
selectionCount: fileManager.selectedFiles.length,
onCancel: () => fileManager.clearSelection(),
onRename: fileManager.selectedFiles.length == 1
? () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
)
: null,
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
true,
),
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
context,
fileManager,
fileManager.selectedFiles,
),
);
}
if (!isDesktop) return const SizedBox.shrink();
return FileBreadcrumb(
currentPath: fileManager.currentPath,
onPathTap: (path) => fileManager.enterFolder(path),
);
},
);
}
void _openFile(BuildContext context, FileModel file) {
if (FileTypeUtils.isImage(file.name)) {
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: file);
} else if (FileTypeUtils.isPdf(file.name)) {
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: file);
} else if (FileTypeUtils.isVideo(file.name)) {
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: file);
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
}
}
Future<void> _downloadFile(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
final task = await downloadManager.addDownloadTask(
fileName: file.name,
fileUri: file.relativePath,
fileSize: file.size,
);
if (task != null) {
if (context.mounted) {
ToastHelper.info('文件已在下载列表中');
}
return;
}
if (context.mounted) {
ToastHelper.info('开始下载,查看任务页');
}
}
Future<void> _openInBrowser(BuildContext context, FileModel file) async {
try {
final response = await FileService().getDownloadUrls(
uris: [file.relativePath],
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
if (context.mounted) {
ToastHelper.error('无法打开链接: $uri');
}
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.failure('获取下载链接失败: $e');
}
}
}
}
@@ -0,0 +1,127 @@
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
import 'package:cloudreve4_flutter/services/avatar_cache_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'widgets/storage_usage_card.dart';
import 'widgets/quick_access_grid.dart';
import 'widgets/recent_activity_list.dart';
import 'widgets/search_entry_card.dart';
class OverviewPage extends StatefulWidget {
const OverviewPage({super.key});
@override
State<OverviewPage> createState() => _OverviewPageState();
}
class _OverviewPageState extends State<OverviewPage> {
@override
void initState() {
super.initState();
Future.microtask(() {
if (mounted) {
final userSetting = Provider.of<UserSettingProvider>(
context, listen: false);
userSetting.loadCapacity();
// 初始化/更新当前用户头像
final auth = Provider.of<AuthProvider>(context, listen: false);
final userId = auth.user?.id ?? '';
if (userId.isNotEmpty) {
final service = AvatarCacheService.instance;
if (service.avatarIsExist(userId)) {
service.avatarIsUpdated(
userId,
auth.currentServer?.baseUrl ?? '',
auth.token?.accessToken ?? '',
);
} else {
service.getAvatar(
userId,
baseUrl: auth.currentServer?.baseUrl,
token: auth.token?.accessToken,
email: auth.user?.email,
);
}
}
}
});
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery
.of(context)
.size
.width >= 720;
return Scaffold(
appBar: AppBar(
title: const Text('概览'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: isWide ? _buildWideLayout() : _buildNarrowLayout(),
),
);
}
/// 宽屏:存储+快捷入口左右并排
Widget _buildWideLayout() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SearchEntryCard(),
SizedBox(height: 16),
_WideStorageAndShortcuts(),
SizedBox(height: 16),
RecentActivityList(),
],
);
}
/// 窄屏:上下堆叠
Widget _buildNarrowLayout() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SearchEntryCard(),
SizedBox(height: 16),
StorageUsageCard(),
SizedBox(height: 16),
Card(child: Padding(
padding: EdgeInsets.all(16), child: QuickAccessGrid())),
SizedBox(height: 16),
RecentActivityList(),
],
);
}
}
/// 宽屏端:左侧存储卡片 + 右侧快捷入口胶囊
class _WideStorageAndShortcuts extends StatelessWidget {
const _WideStorageAndShortcuts();
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Expanded(flex: 5, child: StorageUsageCard()),
SizedBox(width: 16),
Expanded(
flex: 7,
child: Card(
child: Padding(
padding: EdgeInsets.all(20),
child: QuickAccessGrid(fillHeight: true),
),
),
),
],
),
);
}
}
@@ -0,0 +1,295 @@
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
import 'package:cloudreve4_flutter/main.dart' show routeObserver;
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class QuickAccessGrid extends StatefulWidget {
final bool fillHeight;
const QuickAccessGrid({super.key, this.fillHeight = false});
@override
State<QuickAccessGrid> createState() => _QuickAccessGridState();
}
class _QuickAccessGridState extends State<QuickAccessGrid> with RouteAware {
List<QuickAccessConfig> _items = [];
@override
void initState() {
super.initState();
_loadShortcuts();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
_loadShortcuts();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
Future<void> _loadShortcuts() async {
// 先尝试 v2 格式
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
if (saved != null && saved.isNotEmpty) {
try {
if (mounted) setState(() => _items = QuickAccessConfig.parseSaved(saved));
return;
} catch (_) {}
}
// 迁移 v1 格式
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
if (v1 != null && v1.isNotEmpty) {
final migrated = QuickAccessConfig.migrateV1(v1);
if (mounted) {
setState(() => _items = migrated);
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(migrated),
);
}
return;
}
if (mounted) setState(() => _items = List.from(QuickAccessConfig.defaults));
}
void _navigateTo(String path) {
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
navProvider.setIndex(1);
fileManager.enterFolder(path);
}
Future<void> _editShortcut(int index) async {
final item = _items[index];
final labelController = TextEditingController(text: item.label);
final pathController = TextEditingController(text: item.path);
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('编辑 "${item.label}"'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.of(ctx).pop(pathController.text), child: const Text('确定')),
],
),
);
if (result != null && result.isNotEmpty) {
setState(() {
_items[index] = item.copyWith(path: result);
});
await _save();
}
}
Future<void> _save() async {
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(_items),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: widget.fillHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 14),
child: Row(
children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('快捷入口', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
],
),
),
..._buildRows(),
],
);
}
List<Widget> _buildRows() {
final total = _items.length;
if (total == 0) return [];
final maxCols = total > 6 ? 3 : 2;
const gap = 10.0;
final rows = <Widget>[];
for (int i = 0; i < total; i += maxCols) {
final rowItems = <Widget>[];
final remaining = total - i;
final colsInRow = remaining < maxCols ? remaining : maxCols;
for (int j = 0; j < colsInRow; j++) {
final index = i + j;
if (j > 0) rowItems.add(const SizedBox(width: gap));
rowItems.add(
Expanded(
child: _AccessChip(
item: _items[index],
onTap: () => _navigateTo(_items[index].path),
onLongPress: () => _editShortcut(index),
expanded: true,
fillHeight: widget.fillHeight,
),
),
);
}
final row = Row(children: rowItems);
rows.add(widget.fillHeight ? Expanded(child: row) : row);
if (i + maxCols < total) {
rows.add(const SizedBox(height: gap));
}
}
return rows;
}
}
/// 渐变胶囊
class _AccessChip extends StatefulWidget {
final QuickAccessConfig item;
final VoidCallback onTap;
final VoidCallback onLongPress;
final bool expanded;
final bool fillHeight;
const _AccessChip({
required this.item,
required this.onTap,
required this.onLongPress,
this.expanded = false,
this.fillHeight = false,
});
@override
State<_AccessChip> createState() => _AccessChipState();
}
class _AccessChipState extends State<_AccessChip> with SingleTickerProviderStateMixin {
bool _hovered = false;
late final AnimationController _controller;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
lowerBound: 0.94,
upperBound: 1.0,
)..value = 1.0;
_scaleAnim = _controller.view;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final item = widget.item;
final gradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [item.color, item.color.darken(0.12)],
);
final child = AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
width: widget.expanded ? double.infinity : null,
height: widget.fillHeight ? double.infinity : null,
alignment: widget.expanded ? Alignment.center : null,
padding: EdgeInsets.symmetric(
horizontal: widget.expanded ? 0 : 18,
vertical: 12,
),
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: item.color.withValues(alpha: _hovered ? 0.4 : 0.18),
blurRadius: _hovered ? 16 : 8,
offset: Offset(0, _hovered ? 6 : 3),
),
],
),
child: Row(
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 18, color: Colors.white),
const SizedBox(width: 8),
Text(
item.label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: 0.2,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTapDown: (_) => _controller.reverse(),
onTapUp: (_) {
_controller.forward();
widget.onTap();
},
onTapCancel: () => _controller.forward(),
onLongPress: widget.onLongPress,
child: AnimatedBuilder(
animation: _scaleAnim,
builder: (context, _) => Transform.scale(scale: _scaleAnim.value, child: child),
),
),
);
}
}
@@ -0,0 +1,282 @@
import 'dart:io';
import 'package:cloudreve4_flutter/data/models/download_task_model.dart';
import 'package:cloudreve4_flutter/data/models/upload_task_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:provider/provider.dart';
class RecentActivityList extends StatelessWidget {
const RecentActivityList({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 12),
child: Row(
children: [
Icon(LucideIcons.activity, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('最近活动', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
],
),
),
Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadProvider, downloadProvider, _) {
final activities = _mergeActivities(uploadProvider.allTasks, downloadProvider.tasks);
if (activities.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Icon(LucideIcons.activity, size: 40, color: theme.hintColor.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text('暂无活动记录', style: TextStyle(color: theme.hintColor)),
],
),
),
),
);
}
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: activities.take(10).map((item) => _buildActivityItem(context, item)).toList(),
),
);
},
),
],
);
}
List<_ActivityItem> _mergeActivities(
List<UploadTaskModel> uploads,
List<DownloadTaskModel> downloads,
) {
final items = <_ActivityItem>[];
for (final u in uploads) {
items.add(_ActivityItem(
name: u.fileName,
type: _ActivityType.upload,
status: _mapUploadStatus(u.status),
createdAt: DateTime.now(),
path: u.targetPath.replaceFirst('cloudreve://my', ''),
));
}
for (final d in downloads) {
items.add(_ActivityItem(
name: d.fileName,
type: _ActivityType.download,
status: _mapDownloadStatus(d.status),
createdAt: d.createdAt,
path: d.fileUri,
savePath: d.savePath,
));
}
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return items;
}
_ActivityStatus _mapUploadStatus(UploadStatus status) {
switch (status) {
case UploadStatus.completed:
return _ActivityStatus.completed;
case UploadStatus.failed:
return _ActivityStatus.failed;
case UploadStatus.uploading:
return _ActivityStatus.active;
default:
return _ActivityStatus.pending;
}
}
_ActivityStatus _mapDownloadStatus(DownloadStatus status) {
switch (status) {
case DownloadStatus.completed:
return _ActivityStatus.completed;
case DownloadStatus.failed:
return _ActivityStatus.failed;
case DownloadStatus.downloading:
return _ActivityStatus.active;
default:
return _ActivityStatus.pending;
}
}
Widget _buildActivityItem(BuildContext context, _ActivityItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isUpload = item.type == _ActivityType.upload;
final icon = isUpload ? LucideIcons.upload : LucideIcons.download;
final statusColor = _statusColor(item.status, colorScheme);
// 判断点击行为
final bool canTap;
if (item.status == _ActivityStatus.completed) {
if (isUpload) {
canTap = true; // 上传完成 → 跳转目录
} else {
// 下载完成 → 仅桌面端可打开文件夹
canTap = Platform.isWindows || Platform.isLinux;
}
} else {
canTap = false;
}
return InkWell(
onTap: canTap ? () => _handleTap(context, item) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: statusColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
_statusLabel(item),
style: theme.textTheme.bodySmall?.copyWith(color: statusColor),
),
],
),
),
if (canTap) ...[
const SizedBox(width: 8),
Icon(
isUpload ? LucideIcons.folderOpen : LucideIcons.externalLink,
size: 16,
color: theme.hintColor,
),
],
],
),
),
);
}
void _handleTap(BuildContext context, _ActivityItem item) {
if (item.type == _ActivityType.upload) {
_navigateToFolder(context, item.path);
} else {
_openLocalFolder(item.savePath);
}
}
void _navigateToFolder(BuildContext context, String path) {
final parentPath = _getParentPath(path);
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
// 构造高亮路径:还原为 cloudreve:// 格式
final highlightPath = path.startsWith('cloudreve://')
? path
: 'cloudreve://my$path';
fileManager.navigateAndHighlight(parentPath, highlightPath);
navProvider.setIndex(1);
}
Future<void> _openLocalFolder(String? savePath) async {
if (savePath == null || savePath.isEmpty) return;
try {
final dir = File(savePath).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done) {
AppLogger.d('打开文件夹失败: ${result.message}');
}
} catch (e) {
AppLogger.d('打开文件夹失败: $e');
}
}
String _getParentPath(String path) {
if (path.isEmpty || path == '/') return '/';
final parts = path.split('/')..removeLast();
final result = parts.join('/');
return result.isEmpty ? '/' : result;
}
Color _statusColor(_ActivityStatus status, ColorScheme colorScheme) {
switch (status) {
case _ActivityStatus.completed:
return Colors.green;
case _ActivityStatus.failed:
return colorScheme.error;
case _ActivityStatus.active:
return colorScheme.primary;
case _ActivityStatus.pending:
return colorScheme.tertiary;
}
}
String _statusLabel(_ActivityItem item) {
final prefix = item.type == _ActivityType.upload ? '上传' : '下载';
switch (item.status) {
case _ActivityStatus.completed:
return '$prefix完成';
case _ActivityStatus.failed:
return '$prefix失败';
case _ActivityStatus.active:
return '$prefix中...';
case _ActivityStatus.pending:
return '等待$prefix...';
}
}
}
enum _ActivityType { upload, download }
enum _ActivityStatus { completed, failed, active, pending }
class _ActivityItem {
final String name;
final _ActivityType type;
final _ActivityStatus status;
final DateTime createdAt;
final String path;
final String? savePath;
_ActivityItem({
required this.name,
required this.type,
required this.status,
required this.createdAt,
required this.path,
this.savePath,
});
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../widgets/search_dialog.dart';
class SearchEntryCard extends StatelessWidget {
const SearchEntryCard({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: InkWell(
onTap: () => SearchDialog.show(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Icon(LucideIcons.search, size: 22, color: theme.hintColor),
const SizedBox(width: 12),
Text(
'搜索文件...',
style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor),
),
const Spacer(),
Icon(LucideIcons.arrowRight, size: 18, color: theme.hintColor.withValues(alpha: 0.5)),
],
),
),
),
);
}
}
@@ -0,0 +1,136 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
class StorageUsageCard extends StatelessWidget {
const StorageUsageCard({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Consumer<UserSettingProvider>(
builder: (context, userSetting, _) {
final capacity = userSetting.capacity;
final used = capacity?.used ?? 0;
final total = capacity?.total ?? 0;
final percentage = capacity?.usagePercentage ?? 0;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.hardDrive, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('存储空间', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 24),
Center(
child: SizedBox(
width: 160,
height: 90,
child: CustomPaint(
painter: _SemiCircleProgressPainter(
progress: percentage / 100,
color: colorScheme.primary,
backgroundColor: colorScheme.primary.withValues(alpha: 0.12),
),
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
'${_formatBytes(used)} / ${_formatBytes(total)}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
),
),
const SizedBox(height: 4),
Center(
child: Text(
'已使用 ${percentage.toStringAsFixed(1)}%',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
],
),
),
);
},
);
}
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 _SemiCircleProgressPainter extends CustomPainter {
final double progress;
final Color color;
final Color backgroundColor;
_SemiCircleProgressPainter({
required this.progress,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final strokeWidth = 14.0;
final center = Offset(size.width / 2, size.height);
final radius = min(size.width / 2, size.height) - strokeWidth / 2;
final bgPaint = Paint()
..color = backgroundColor
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
pi,
pi,
false,
bgPaint,
);
final fgPaint = Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final sweepAngle = pi * progress.clamp(0.0, 1.0);
if (sweepAngle > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
pi,
sweepAngle,
false,
fgPaint,
);
}
}
@override
bool shouldRepaint(covariant _SemiCircleProgressPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
@@ -0,0 +1,409 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
/// 音频预览页面
class AudioPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const AudioPreviewPage({super.key, required this.file, this.entityId});
@override
State<AudioPreviewPage> createState() => _AudioPreviewPageState();
}
class _AudioPreviewPageState extends State<AudioPreviewPage> {
late final Player player;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
player = Player();
_loadAudioUrl();
}
Future<void> _loadAudioUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_isLoading = false;
});
player.open(Media(url), play: true);
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取音频URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
void dispose() {
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A2E),
appBar: AppBar(
title: Text(
widget.file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
backgroundColor: const Color(0xFF1A1A2E),
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.white54,
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadAudioUrl();
},
child: const Text('重试'),
),
],
),
);
}
return AudioPlayerWidget(player: player, fileName: widget.file.name);
}
}
/// 音频播放器组件
class AudioPlayerWidget extends StatefulWidget {
final Player player;
final String fileName;
const AudioPlayerWidget({super.key, required this.player, required this.fileName});
@override
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
}
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1A1A2E),
const Color(0xFF16213E),
],
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 专辑封面
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFE94560), Color(0xFF533483)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFFE94560).withValues(alpha: 0.3),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.music_note,
size: 120,
color: Colors.white,
),
),
const SizedBox(height: 48),
// 文件名
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.fileName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
// 文件类型
Text(
'音频文件',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 48),
// 进度条
_buildProgressBar(),
const SizedBox(height: 32),
// 播放控制
_buildPlaybackControls(),
],
),
),
);
}
Widget _buildProgressBar() {
return Column(
children: [
StreamBuilder(
stream: widget.player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return StreamBuilder(
stream: widget.player.stream.duration,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
return SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
thumbColor: const Color(0xFFE94560),
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3),
),
child: Slider(
value: position.inMilliseconds.toDouble(),
max: duration.inMilliseconds > 0
? duration.inMilliseconds.toDouble()
: 1.0,
onChanged: (value) {
widget.player.seek(
Duration(milliseconds: value.toInt()),
);
},
),
);
},
);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: StreamBuilder(
stream: widget.player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return StreamBuilder(
stream: widget.player.stream.duration,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(position),
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.6),
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
);
},
);
},
),
),
],
);
}
Widget _buildPlaybackControls() {
final screenWidth = MediaQuery.of(context).size.width;
final isSmallScreen = screenWidth < 400;
return Padding(
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 8 : 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 上一首(暂无功能)
Flexible(
child: IconButton(
icon: const Icon(Icons.skip_previous),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {},
),
),
// 快退10秒
Flexible(
child: IconButton(
icon: const Icon(Icons.replay_10),
iconSize: isSmallScreen ? 32 : 42,
color: Colors.white,
onPressed: () {
final position = widget.player.state.position;
widget.player.seek(position - const Duration(seconds: 10));
},
),
),
// 播放/暂停
StreamBuilder(
stream: widget.player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return Container(
width: isSmallScreen ? 56 : 72,
height: isSmallScreen ? 56 : 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE94560),
boxShadow: [
BoxShadow(
color: const Color(0xFFE94560).withValues(alpha: 0.4),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {
if (playing) {
widget.player.pause();
} else {
widget.player.play();
}
},
),
);
},
),
// 快进10秒
Flexible(
child: IconButton(
icon: const Icon(Icons.forward_10),
iconSize: isSmallScreen ? 32 : 42,
color: Colors.white,
onPressed: () {
final position = widget.player.state.position;
widget.player.seek(position + const Duration(seconds: 10));
},
),
),
// 下一首(暂无功能)
Flexible(
child: IconButton(
icon: const Icon(Icons.skip_next),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {},
),
),
],
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,256 @@
import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/language_preview.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:code_text_field/code_text_field.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:highlight/highlight.dart';
import 'package:highlight/languages/all.dart';
import 'package:http/http.dart' as http;
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import '../../../core/utils/file_type_utils.dart';
/// 文档预览页面
class DocumentPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const DocumentPreviewPage({super.key, required this.file, this.entityId});
@override
State<DocumentPreviewPage> createState() => _DocumentPreviewPageState();
}
class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
CodeController? _codeController;
static const _backgroundColor = Color(0xFF292d3e);
String _content = '';
bool _isLoading = true;
String? _error;
Mode _languageMode = allLanguages['plaintext']!;
String _languageName = 'Text';
int _lineCount = 0;
final ScrollController _customCodeScrollController = ScrollController();
// 状态管理
bool _showLineNumbers = true;
double _fontSize = 14.0; // 默认字体大小
bool _hasInitializedLayout = false;
@override
void initState() {
super.initState();
_loadFileContent();
}
@override
void dispose() {
_codeController?.dispose();
_customCodeScrollController.dispose();
super.dispose();
}
// 加载逻辑保持不变...
Future<void> _loadFileContent() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: true,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) {
throw Exception('下载文件失败: ${responseContent.statusCode}');
}
if (mounted) {
setState(() {
_content = responseContent.body;
_lineCount = _countLines(_content);
_languageMode = _detectLanguageMode(widget.file.name);
_languageName = _getLanguageNameFromExtension(widget.file.name);
_initCodeController();
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
throw Exception('获取文件内容失败: $_error');
});
}
}
}
void _initCodeController() {
_codeController = CodeController(text: _content, language: _languageMode);
}
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
Mode _detectLanguageMode(String fileName) {
final ext = FileTypeUtils.getExtension(fileName);
final extLang = LanguagePreview.getExtNameMapping[ext] ?? ext.toUpperCase();
return allLanguages[extLang.toLowerCase()] ?? allLanguages['plaintext']!;
}
String _getLanguageNameFromExtension(String fileName) {
final ext = FileTypeUtils.getExtension(fileName).toLowerCase();
return LanguagePreview.getExtNameMapping[ext] ?? ext.toUpperCase();
}
@override
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
// 自动初始化布局逻辑
if (!_hasInitializedLayout && !_isLoading) {
_showLineNumbers = screenWidth >= 600;
_hasInitializedLayout = true;
}
// --- 动态行号宽度计算 (核心修复) ---
double lineNumberWidth = 0;
if (_showLineNumbers) {
// 计算行数的位数,并根据当前字体大小分配宽度
int digits = _lineCount.toString().length;
// 这里的 0.7 是字体宽高的约数比例,20 是左右边距预留
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
if (lineNumberWidth < 35) lineNumberWidth = 35;
}
return Scaffold(
backgroundColor: _backgroundColor,
appBar: AppBar(
backgroundColor: _backgroundColor,
elevation: 0,
iconTheme: const IconThemeData(
color: Colors.white,
),
title: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,),
if (!_isLoading)
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,),
],
),
actions: [
IconButton(
icon: const Icon(Icons.copy, color: Colors.white),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: _buildCodeEditor(lineNumberWidth),
floatingActionButton: _buildExpandableFab(),
);
}
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
Widget _buildCodeEditor(double lineNumberWidth) {
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
int digits = _lineCount.toString().length;
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
double stableWidth = _showLineNumbers
? (digits * (_fontSize * 0.75)) + 46
: 0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(12),
),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: CodeTheme(
data: CodeThemeData(styles: {...atomOneDarkTheme}),
child: Scrollbar(
controller: _customCodeScrollController,
child: SingleChildScrollView(
controller: _customCodeScrollController,
child: CodeField(
controller: _codeController!,
textStyle: TextStyle(
fontFamily: 'SourceCodePro',
fontSize: _fontSize,
height: 1.5,
),
enabled: false,
readOnly: true,
background: Colors.transparent,
// 关键点 1:调整行号样式
lineNumberStyle: LineNumberStyle(
width: stableWidth,
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
textStyle: TextStyle(
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent,
fontSize: _fontSize * 0.8,
height: 1.5, // 必须和正文高度完全一致
// 核心修复:通过强制单词不换行来防止数字断裂
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字
),
),
),
),
),
),
),
);
}
// 构建功能组合按钮
Widget _buildExpandableFab() {
if (_isLoading) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 增加字号
FloatingActionButton(
heroTag: 'font_up',
mini: true,
backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }),
child: const Icon(Icons.add, color: Colors.white, size: 20),
),
const SizedBox(height: 8),
// 减小字号
FloatingActionButton(
heroTag: 'font_down',
mini: true,
backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }),
child: const Icon(Icons.remove, color: Colors.white, size: 20),
),
const SizedBox(height: 8),
// 行号开关
FloatingActionButton(
heroTag: 'line_toggle',
mini: true,
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800,
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
child: Icon(
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
color: Colors.white,
size: 20,
),
),
],
);
}
}
@@ -0,0 +1,223 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import '../../../services/cache_manager_service.dart';
import '../../widgets/toast_helper.dart';
/// 图片预览页面
class ImagePreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const ImagePreviewPage({super.key, required this.file, this.entityId});
@override
State<ImagePreviewPage> createState() => _ImagePreviewPageState();
}
class _ImagePreviewPageState extends State<ImagePreviewPage> {
String? _imageUrl;
bool _isLoading = true;
String? _errorMessage;
// 定义一个变量,防止多个动画冲突
bool _isAnimating = false;
@override
void initState() {
super.initState();
_loadImageUrl();
}
Future<void> _loadImageUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_imageUrl = url;
_isLoading = false;
});
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取图片URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.file.name),
actions: [
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'bingo') {
_bingoHahah();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'bingo',
child: Row(
children: [
Icon(Icons.handshake, size: 20),
SizedBox(width: 12),
Text('bingo'),
],
),
),
],
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadImageUrl();
},
child: const Text('重试'),
),
],
),
);
}
if (_imageUrl == null) {
return const Center(child: Text('无法加载图片'));
}
return _buildPhotoView();
}
void _bingoHahah() {
ToastHelper.info('彩蛋彩蛋彩蛋蛋, 对下联');
}
Listener _buildPhotoView() {
// 1. 自定义控制器, 用于支持Linux/Windows 键盘 Ctrl + 鼠标滚轮缩放图片
final PhotoViewController photoController = PhotoViewController();
// 2. 包装组件
return Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
// 检查是否按下了 Ctrl 键 (在 Linux/Windows 上很常用)
// 如果你希望直接滚动滚轮就缩放,可以去掉 RawKeyboardGui... 这一行判断
final isControlPressed =
HardwareKeyboard.instance.logicalKeysPressed.contains(
LogicalKeyboardKey.controlLeft,
) ||
HardwareKeyboard.instance.logicalKeysPressed.contains(
LogicalKeyboardKey.controlRight,
);
// 计算缩放增量:向上滚为负,向下滚为正
// 这里的 0.001 是灵敏度系数,可以根据手感调整
if (isControlPressed) {
if (photoController.scale == null) return;
// double newScale =
// photoController.scale! - (pointerSignal.scrollDelta.dy * 0.001);
// 限制缩放范围,防止无限缩小或放大
_smoothScale(photoController, pointerSignal.scrollDelta.dy);
}
}
},
child: PhotoView(
controller: photoController, // 绑定控制器
imageProvider: CachedNetworkImageProvider(
_imageUrl!,
cacheManager: CacheManagerService.instance.manager,
),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
initialScale: PhotoViewComputedScale.contained,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
loadingBuilder: (context, event) =>
const Center(child: CircularProgressIndicator()),
errorBuilder: (context, error, stackTrace) => const Center(
child: Icon(Icons.error_outline, size: 48, color: Colors.red),
),
),
);
}
// 2. 编写平滑缩放函数
void _smoothScale(PhotoViewController photoController, double delta) {
// 如果正在动画中,忽略新的滚轮脉冲,防止冲突
if (_isAnimating) return;
_isAnimating = true;
// 计算目标缩放值
double targetScale = (photoController.scale ?? 1.0) - (delta * 0.001);
targetScale = targetScale.clamp(0.1, 5.0);
// 如果不想写复杂的 AnimationController,可以用这种简易插值
// 这里的 10 次循环和 5 毫秒延迟可以根据你的手感微调
int steps = 5;
double stepDelta = (targetScale - photoController.scale!) / steps;
Future.doWhile(() async {
if (steps <= 0) {
_isAnimating = false;
return false;
}
photoController.scale = photoController.scale! + stepDelta;
steps--;
await Future.delayed(const Duration(milliseconds: 16)); // 约 60 帧的速度
return true;
});
}
}
@@ -0,0 +1,311 @@
import 'package:cloudreve4_flutter/presentation/widgets/code_wrapper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:http/http.dart' as http;
import 'package:markdown_widget/config/configs.dart';
import 'package:markdown_widget/config/toc.dart';
import 'package:markdown_widget/widget/blocks/leaf/code_block.dart';
import 'package:markdown_widget/widget/blocks/leaf/link.dart';
import 'package:markdown_widget/widget/markdown.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
class MarkdownPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const MarkdownPreviewPage({super.key, required this.file, this.entityId});
@override
State<MarkdownPreviewPage> createState() => _MarkdownPreviewPageState();
}
class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
String _content = '';
bool _isLoading = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final _tocController = TocController();
bool _isDarkMode = false;
// 控制目录是否显示
bool _isTocVisible = false;
// 标记是否已经根据屏幕宽度进行了初始化
bool _hasInitializedLayout = false;
@override
void initState() {
super.initState();
_loadFileContent();
}
@override
void dispose() {
_scrollController.dispose();
_tocController.dispose();
super.dispose();
}
Future<void> _loadFileContent() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: true,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) {
throw Exception('下载文件失败: ${responseContent.statusCode}');
}
if (mounted) {
setState(() {
_content = responseContent.body;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
final bool isWideScreen = screenWidth >= 1000;
// 首次加载时,根据屏幕宽度决定目录默认状态
if (!_hasInitializedLayout && !_isLoading) {
_isTocVisible = isWideScreen;
_hasInitializedLayout = true;
}
final isDark = _isDarkMode;
final bgColor = isDark ? const Color(0xFF1E1E1E) : Colors.white;
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
backgroundColor: bgColor,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87),
onPressed: () => Navigator.of(context).pop(),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: TextStyle(
color: isDark ? Colors.white : Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
if (!_isLoading && _error == null)
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)),
],
),
actions: [
if (!_isLoading && _error == null)
IconButton(
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildErrorWidget()
: _buildResponsiveBody(isWideScreen, screenWidth),
floatingActionButton: _buildFAB(isDark),
);
}
// 构建响应式主体
Widget _buildResponsiveBody(bool isWideScreen, double screenWidth) {
final double tocWidth = isWideScreen ? 300 : screenWidth * 0.7;
// 预定义暗色/亮色下的文字颜色
final Color textColor = _isDarkMode ? Colors.white : Colors.black87;
final Color subTextColor = _isDarkMode ? Colors.white70 : Colors.black54;
return Row(
children: [
// 1. 侧边目录区
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _isTocVisible ? tocWidth : 0,
child: Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2)
),
),
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50,
),
child: ClipRect(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"目录",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textColor, // 适配标题颜色
),
),
),
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300),
Expanded(
child: GestureDetector(
onTap: () {
if (!isWideScreen) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) setState(() => _isTocVisible = false);
});
}
},
behavior: HitTestBehavior.translucent,
// 使用 Theme 局部包裹,强制改变 TOC 内部的 TextTheme
child: Theme(
data: Theme.of(context).copyWith(
textTheme: TextTheme(
// 某些版本的 markdown_widget 会引用 bodyMedium 或 bodySmall
bodyMedium: TextStyle(color: subTextColor),
bodySmall: TextStyle(color: subTextColor),
),
),
child: TocWidget(
controller: _tocController,
),
),
),
),
],
),
),
),
),
// 2. 正文内容区
Expanded(
child: Container(
color: _isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
child: SafeArea(
left: false,
right: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), // 增加一点边距
child: _buildMarkdownWidget(_content),
),
),
),
),
],
);
}
Widget _buildMarkdownWidget(String content) {
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language);
return MarkdownWidget(
data: content,
tocController: _tocController,
config: config.copy(
configs: [
_isDarkMode
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper)
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
LinkConfig(
style: TextStyle(
color: _isDarkMode ? Colors.lightBlue : Colors.blue,
decoration: TextDecoration.underline,
),
onTap: (url) => launchUrl(Uri.parse(url)),
),
],
),
selectable: true,
);
}
Widget _buildErrorWidget() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_error!),
ElevatedButton(onPressed: _loadFileContent, child: const Text('重试')),
],
),
);
}
Widget _buildFAB(bool isDark) {
if (_isLoading || _error != null) return const SizedBox.shrink();
// 定义统一的按钮背景颜色,增加视觉一致性
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200;
final Color iconColor = isDark ? Colors.white : Colors.black87;
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 1. 暗色模式切换按钮
FloatingActionButton(
heroTag: 'theme_toggle',
mini: true, // 保持 mini
backgroundColor: inactiveColor,
elevation: 2, // 稍微降低阴影,看起来更精致
onPressed: () => setState(() => _isDarkMode = !_isDarkMode),
child: Icon(
isDark ? Icons.light_mode : Icons.dark_mode,
color: iconColor,
size: 20, // 微调图标大小
),
),
const SizedBox(height: 12),
// 2. 目录切换按钮
FloatingActionButton(
heroTag: 'toc_toggle',
mini: true, // 这里修改为 true,使其与上面的按钮大小一致
// 如果目录开启,使用蓝色背景,否则使用普通背景
backgroundColor: _isTocVisible ? activeColor : inactiveColor,
elevation: 2,
onPressed: () => setState(() => _isTocVisible = !_isTocVisible),
child: Icon(
Icons.format_list_bulleted,
color: _isTocVisible ? Colors.white : iconColor,
size: 20,
),
),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
/// PDF预览页面
class PdfPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const PdfPreviewPage({super.key, required this.file, this.entityId});
@override
State<PdfPreviewPage> createState() => _PdfPreviewPageState();
}
class _PdfPreviewPageState extends State<PdfPreviewPage> {
String? _pdfUrl;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadPdfUrl();
}
Future<void> _loadPdfUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_pdfUrl = url;
_isLoading = false;
});
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取PDF URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.file.name)),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadPdfUrl();
},
child: const Text('重试'),
),
],
),
);
}
if (_pdfUrl == null) {
return const Center(child: Text('无法加载PDF'));
}
return Container(
color: Colors.grey.shade200,
child: PdfViewer.uri(
Uri.parse(_pdfUrl!),
initialPageNumber: 1,
params: const PdfViewerParams(
activeMatchTextColor: Colors.yellow,
annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms,
maxScale: 4.0,
minScale: 0.8, // Allow 300% zoom
scaleEnabled: true,
textSelectionParams: PdfTextSelectionParams(
enabled: true,
showContextMenuAutomatically: true,
),
),
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import 'widgets/video_controls_overlay.dart';
/// 视频预览页面
class VideoPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const VideoPreviewPage({super.key, required this.file, this.entityId});
@override
State<VideoPreviewPage> createState() => _VideoPreviewPageState();
}
class _VideoPreviewPageState extends State<VideoPreviewPage> {
late final Player player;
late final VideoController controller;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
player = Player();
controller = VideoController(player);
_loadVideoUrl();
}
Future<void> _loadVideoUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() => _isLoading = false);
player.open(Media(url), play: true);
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取视频URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
void dispose() {
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.white),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadVideoUrl();
},
child: const Text('重试'),
),
],
),
)
: ExcludeSemantics(
child: Video(
controller: controller,
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name),
),
),
);
}
}
@@ -0,0 +1,764 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../core/utils/video_fullscreen.dart';
/// 自定义视频控制栏叠加层(Bilibili 风格)
class VideoControlsOverlay extends StatefulWidget {
final VideoState state;
final String title;
const VideoControlsOverlay({super.key, required this.state, required this.title});
@override
State<VideoControlsOverlay> createState() => _VideoControlsOverlayState();
}
class _VideoControlsOverlayState extends State<VideoControlsOverlay> with WindowListener {
Player get player => widget.state.widget.controller.player;
bool _controlsVisible = true;
Timer? _hideTimer;
bool _isLongPressing = false;
double _rateBeforeLongPress = 1.0;
// 进度条拖拽状态
bool _isSeeking = false;
Duration _seekPosition = Duration.zero;
// 音量控制
bool _volumeSliderVisible = false;
double _volumeBeforeMute = 100.0;
// 自管全屏状态
bool _isFullscreen = false;
// 键盘焦点
final FocusNode _focusNode = FocusNode();
// 桌面端鼠标悬停
Timer? _mouseHoverTimer;
bool get _isDesktop => Platform.isWindows || Platform.isLinux || Platform.isMacOS;
@override
void initState() {
super.initState();
_startHideTimer();
if (_isDesktop) {
windowManager.addListener(this);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_focusNode.dispose();
if (_isDesktop) {
windowManager.removeListener(this);
}
if (_isFullscreen) {
_doExitFullscreen();
}
super.dispose();
}
// ========== WindowListener(桌面端) ==========
@override
void onWindowEnterFullScreen() {
if (!_isFullscreen && mounted) {
setState(() => _isFullscreen = true);
}
}
@override
void onWindowLeaveFullScreen() {
if (_isFullscreen && mounted) {
setState(() => _isFullscreen = false);
videoFullscreenNotifier.value = false;
}
}
// ========== 全屏控制 ==========
Future<void> _toggleFullscreen() async {
if (_isFullscreen) {
await _doExitFullscreen();
} else {
await _doEnterFullscreen();
}
if (mounted) setState(() {});
}
Future<void> _doEnterFullscreen() async {
_isFullscreen = true;
if (_isDesktop) {
videoFullscreenNotifier.value = true;
await windowManager.setFullScreen(true);
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []),
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]),
]);
}
}
Future<void> _doExitFullscreen() async {
_isFullscreen = false;
if (_isDesktop) {
await windowManager.setFullScreen(false);
videoFullscreenNotifier.value = false;
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values),
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
]);
}
}
void _onBack() {
if (_isFullscreen) {
_doExitFullscreen().then((_) {
if (mounted) setState(() {});
});
} else {
Navigator.of(context).pop();
}
}
// ========== 控制栏显隐 ==========
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted && !_isLongPressing) {
_hideControls();
}
});
}
void _hideControls() {
setState(() {
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _showControls() {
setState(() {
_controlsVisible = true;
});
_startHideTimer();
}
void _toggleControls() {
if (_controlsVisible) {
_hideControls();
} else {
_showControls();
}
}
void _onLongPressStart() {
_hideTimer?.cancel();
_rateBeforeLongPress = player.state.rate;
player.setRate(2.0);
setState(() {
_isLongPressing = true;
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _onLongPressEnd() {
player.setRate(_rateBeforeLongPress);
setState(() {
_isLongPressing = false;
_controlsVisible = true;
});
_startHideTimer();
}
// ========== 桌面端鼠标悬停 ==========
void _onMouseHover(PointerHoverEvent event) {
if (!_controlsVisible) {
setState(() => _controlsVisible = true);
}
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
void _onMouseExit(PointerExitEvent event) {
_mouseHoverTimer?.cancel();
_startHideTimer();
}
// ========== 键盘快捷键 ==========
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.space) {
player.playOrPause();
} else if (key == LogicalKeyboardKey.keyM) {
_toggleMute();
} else if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
_toggleFullscreen();
} else if (key == LogicalKeyboardKey.escape) {
if (_isFullscreen) _onBack();
} else if (key == LogicalKeyboardKey.arrowLeft) {
final pos = player.state.position - const Duration(seconds: 5);
player.seek(pos < Duration.zero ? Duration.zero : pos);
} else if (key == LogicalKeyboardKey.arrowRight) {
final pos = player.state.position + const Duration(seconds: 5);
final dur = player.state.duration;
player.seek(pos > dur ? dur : pos);
} else if (key == LogicalKeyboardKey.arrowUp) {
final vol = (player.state.volume + 5).clamp(0.0, 100.0);
player.setVolume(vol);
} else if (key == LogicalKeyboardKey.arrowDown) {
final vol = (player.state.volume - 5).clamp(0.0, 100.0);
player.setVolume(vol);
}
}
void _toggleMute() {
if (player.state.volume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = player.state.volume;
player.setVolume(0);
}
}
// ========== 音量图标点击 ==========
void _onVolumeIconTap(double currentVolume) {
if (_volumeSliderVisible) {
// 滑条已展开,再次点击小喇叭 → 静音
if (currentVolume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = currentVolume;
player.setVolume(0);
}
} else {
// 滑条未展开,展开滑条
_hideTimer?.cancel();
setState(() => _volumeSliderVisible = true);
}
}
// ========== 倍速菜单 ==========
void _showSpeedMenu() {
_hideTimer?.cancel();
final rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
final currentRate = player.state.rate;
showModalBottomSheet(
context: context,
backgroundColor: Colors.black87,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text('倍速播放',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.white70,
)),
),
const Divider(height: 1, color: Colors.white24),
Wrap(
children: rates.map((rate) {
final selected = (rate - currentRate).abs() < 0.01;
return SizedBox(
width: (MediaQuery.of(context).size.width - 32) / 4,
child: ListTile(
title: Text(
'${rate}x',
textAlign: TextAlign.center,
style: TextStyle(
color: selected ? const Color(0xFFE94560) : Colors.white,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
onTap: () {
player.setRate(rate);
Navigator.of(ctx).pop();
_startHideTimer();
},
),
);
}).toList(),
),
const SizedBox(height: 8),
],
),
),
);
}
// ========== 工具方法 ==========
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
// ========== 构建 UI ==========
@override
Widget build(BuildContext context) {
Widget overlay = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleControls,
onDoubleTap: () => player.playOrPause(),
onLongPressStart: (_) => _onLongPressStart(),
onLongPressEnd: (_) => _onLongPressEnd(),
child: Stack(
fit: StackFit.expand,
children: [
// 长按2倍速提示
if (_isLongPressing)
Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fast_forward, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
'2.0x 快进中',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 4)],
),
),
],
),
),
),
// Buffering 指示器
StreamBuilder<bool>(
stream: player.stream.buffering,
builder: (context, snapshot) {
final buffering = snapshot.data ?? false;
if (!buffering) return const SizedBox.shrink();
return const Center(
child: CircularProgressIndicator(color: Colors.white70),
);
},
),
// 控制栏
AnimatedOpacity(
opacity: _controlsVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: Column(
children: [
_buildTopBar(),
const Spacer(),
_buildBottomBar(),
],
),
),
),
// 桌面端快捷键提示
if (_isDesktop)
AnimatedOpacity(
opacity: _controlsVisible ? 0.4 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: _buildShortcutsHint(),
),
),
],
),
);
// 桌面端:鼠标悬停 + 键盘
if (_isDesktop) {
overlay = MouseRegion(
onHover: _onMouseHover,
onExit: _onMouseExit,
child: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: overlay,
),
);
}
return PopScope(
canPop: !_isFullscreen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _onBack();
},
child: overlay,
);
}
Widget _buildTopBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 4,
left: 4,
right: 4,
bottom: 8,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Expanded(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSeekBar(),
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: _buildControlsRow(),
),
],
),
);
}
Widget _buildControlsRow() {
return Row(
children: [
// 播放/暂停
StreamBuilder<bool>(
stream: player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow, color: Colors.white),
onPressed: () => player.playOrPause(),
);
},
),
// 音量控制
_buildVolumeControl(),
// 时间
StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final pos = posSnapshot.data ?? Duration.zero;
final dur = durSnapshot.data ?? Duration.zero;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${_formatDuration(pos)} / ${_formatDuration(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
);
},
);
},
),
const Spacer(),
// 倍速按钮
StreamBuilder<double>(
stream: player.stream.rate,
builder: (context, snapshot) {
final rate = snapshot.data ?? 1.0;
return GestureDetector(
onTap: _showSpeedMenu,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
'${rate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
);
},
),
// 全屏按钮
IconButton(
icon: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: _toggleFullscreen,
),
],
);
}
Widget _buildVolumeControl() {
return StreamBuilder<double>(
stream: player.stream.volume,
builder: (context, snapshot) {
final volume = snapshot.data ?? 100.0;
final isMuted = volume <= 0;
IconData volumeIcon;
if (isMuted) {
volumeIcon = Icons.volume_off;
} else if (volume < 50) {
volumeIcon = Icons.volume_down;
} else {
volumeIcon = Icons.volume_up;
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _onVolumeIconTap(volume),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(volumeIcon, color: Colors.white, size: 22),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.centerLeft,
child: _volumeSliderVisible
? SizedBox(
width: 100,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: volume.clamp(0.0, 100.0),
min: 0,
max: 100,
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
player.setVolume(v);
},
onChangeEnd: (_) {
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
)
: const SizedBox(width: 0),
),
],
);
},
);
}
void _startMouseHoverTimer() {
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
Widget _buildSeekBar() {
return StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final position = posSnapshot.data ?? Duration.zero;
final duration = durSnapshot.data ?? Duration.zero;
final displayPos = _isSeeking ? _seekPosition : position;
final value = duration.inMilliseconds > 0
? displayPos.inMilliseconds / duration.inMilliseconds
: 0.0;
return SizedBox(
height: 20,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: value.clamp(0.0, 1.0),
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
setState(() {
_isSeeking = true;
_seekPosition = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
});
},
onChangeEnd: (v) {
final seekTo = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
player.seek(seekTo);
setState(() => _isSeeking = false);
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
);
},
);
},
);
}
Widget _buildShortcutsHint() {
const shortcuts = [
('Space', '暂停/播放'),
('M', '静音'),
('Enter', '全屏'),
('Esc', '退出全屏'),
('←→', '快退/快进 5s'),
('↑↓', '音量 ±5'),
];
return Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(
right: 12,
top: MediaQuery.of(context).padding.top + 48,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: shortcuts.map((s) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 42,
child: Text(
s.$1,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
Text(
s.$2,
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
);
}).toList(),
),
),
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/profile_info_card.dart';
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/quick_functions_section.dart';
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/admin_section.dart';
import 'package:cloudreve4_flutter/services/avatar_cache_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// "我的"页面
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_initData();
});
}
Future<void> _initData() async {
final userSetting = context.read<UserSettingProvider>();
userSetting.loadCapacity();
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAdmin) return;
final adminProvider = context.read<AdminProvider>();
if (adminProvider.groups.isNotEmpty || adminProvider.users.isNotEmpty) return;
await adminProvider.loadAll();
if (!mounted) return;
// 批量检查头像更新,限制并发避免阻塞主线程
final users = adminProvider.users;
final baseUrl = authProvider.currentServer?.baseUrl ?? '';
final token = authProvider.token?.accessToken ?? '';
final needCheckIds = <String>[];
for (final user in users) {
final userId = user.hashId ?? user.id.toString();
if (AvatarCacheService.instance.avatarIsExist(userId)) {
needCheckIds.add(userId);
}
}
if (needCheckIds.isNotEmpty) {
AvatarCacheService.instance.batchCheckUpdates(
needCheckIds,
baseUrl: baseUrl,
token: token,
);
}
}
@override
Widget build(BuildContext context) {
final isAdmin = context.select<AuthProvider, bool>((p) => p.isAdmin);
return Scaffold(
appBar: AppBar(title: const Text('我的')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProfileInfoCard(),
const SizedBox(height: 16),
const QuickFunctionsSection(),
if (isAdmin) ...[
const SizedBox(height: 16),
const AdminSection(),
],
const SizedBox(height: 16),
],
),
),
);
}
}
@@ -0,0 +1,838 @@
import 'package:cloudreve4_flutter/data/models/admin_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
/// 管理员功能区域
class AdminSection extends StatelessWidget {
const AdminSection({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 14),
child: Row(
children: [
Icon(LucideIcons.shield, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('管理',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
],
),
),
Selector<AdminProvider, bool>(
selector: (_, p) => p.isLoading,
builder: (_, isLoading, _) {
if (isLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
return Column(
children: [
Selector<AdminProvider, (List<AdminGroupModel>, PaginationModel?)>(
selector: (_, p) => (p.groups, p.groupsPagination),
builder: (_, data, _) => _GroupsCard(groups: data.$1, pagination: data.$2),
),
const SizedBox(height: 12),
Selector<AdminProvider, (List<AdminUserModel>, PaginationModel?)>(
selector: (_, p) => (p.users, p.usersPagination),
builder: (_, data, _) => _UsersCard(users: data.$1, pagination: data.$2),
),
],
);
},
),
],
);
}
}
// ==================== 用户组卡片 ====================
class _GroupsCard extends StatelessWidget {
final List<AdminGroupModel> groups;
final PaginationModel? pagination;
const _GroupsCard({required this.groups, this.pagination});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.users, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('用户组',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
if (pagination != null)
Text(
'${pagination!.totalItems}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(LucideIcons.plus, size: 18),
onPressed: () => _showCreateGroupDialog(context),
tooltip: '创建用户组',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 12),
if (groups.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: theme.hintColor)),
),
)
else
...groups.map((group) => _GroupItem(group: group)),
],
),
),
);
}
void _showCreateGroupDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => _StyledDialog(
title: '创建用户组',
icon: LucideIcons.users,
child: TextField(
controller: controller,
autofocus: true,
decoration: _StyledInputDecoration(
labelText: '用户组名称',
hintText: '请输入用户组名称',
),
),
onConfirm: () {
final name = controller.text.trim();
if (name.isEmpty) return false;
Navigator.of(ctx).pop();
context.read<AdminProvider>().createGroup(name).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('创建成功');
} else {
ToastHelper.failure('创建失败');
}
}
});
return true;
},
),
);
}
}
class _GroupItem extends StatelessWidget {
final AdminGroupModel group;
const _GroupItem({required this.group});
bool get _isAdmin => group.name.toLowerCase() == 'admin' || group.name == '管理员';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
group.name[0],
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(group.name,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'ID: ${group.id}${group.formattedMaxStorage}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
],
),
),
if (_isAdmin)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text('管理员',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600)),
)
else
IconButton(
icon: Icon(LucideIcons.trash2, size: 16, color: colorScheme.error.withValues(alpha: 0.7)),
onPressed: () => _confirmDeleteGroup(context, group),
tooltip: '删除',
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
void _confirmDeleteGroup(BuildContext context, AdminGroupModel group) {
showDialog(
context: context,
builder: (ctx) => _StyledConfirmDialog(
title: '删除用户组',
message: '确定要删除用户组「${group.name}」吗?',
icon: LucideIcons.trash2,
isDestructive: true,
onConfirm: () {
Navigator.of(ctx).pop();
context.read<AdminProvider>().deleteGroup(group.id).then((error) {
if (context.mounted) {
if (error != null) {
ToastHelper.failure(error);
} else {
ToastHelper.success('已删除');
}
}
});
},
),
);
}
}
// ==================== 用户卡片 ====================
class _UsersCard extends StatelessWidget {
final List<AdminUserModel> users;
final PaginationModel? pagination;
const _UsersCard({required this.users, this.pagination});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final adminProvider = context.read<AdminProvider>();
return Selector<AdminProvider, (bool, int)>(
selector: (_, p) => (p.isSelectingUsers, p.selectedUserIds.length),
builder: (_, data, _) {
final isSelecting = data.$1;
final selectedCount = data.$2;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.user, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('用户',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
if (pagination != null)
Text(
'${pagination!.totalItems}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(width: 8),
if (isSelecting) ...[
if (selectedCount > 0)
TextButton.icon(
onPressed: () => _confirmBatchDelete(context, adminProvider.selectedUserIds.toList()),
icon: Icon(LucideIcons.trash2, size: 16, color: colorScheme.error),
label: Text('删除 ($selectedCount)',
style: TextStyle(color: colorScheme.error)),
style: TextButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
IconButton.outlined(
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => adminProvider.exitSelectMode(),
tooltip: '取消选择',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
] else ...[
IconButton.outlined(
icon: const Icon(LucideIcons.checkSquare, size: 18),
onPressed: () => adminProvider.toggleSelectMode(),
tooltip: '多选',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
const SizedBox(width: 4),
IconButton.outlined(
icon: const Icon(LucideIcons.plus, size: 18),
onPressed: () => _showCreateUserDialog(context),
tooltip: '创建用户',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
],
],
),
if (isSelecting)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
TextButton(
onPressed: () => adminProvider.selectAllUsers(),
child: const Text('全选'),
),
TextButton(
onPressed: () => adminProvider.clearUserSelection(),
child: const Text('取消全选'),
),
],
),
),
const SizedBox(height: 8),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: theme.hintColor)),
),
)
else
...users.map((user) => _UserItem(user: user)),
],
),
),
);
},
);
}
void _showCreateUserDialog(BuildContext context) {
final emailController = TextEditingController();
final nickController = TextEditingController();
final passwordController = TextEditingController();
final groups = context.read<AdminProvider>().groups;
int? selectedGroupId;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => _StyledDialog(
title: '创建用户',
icon: LucideIcons.userPlus,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: _StyledInputDecoration(
labelText: '邮箱',
hintText: 'user@example.com',
prefixIcon: Icon(LucideIcons.mail, size: 18),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: nickController,
decoration: _StyledInputDecoration(
labelText: '昵称',
hintText: '请输入昵称',
prefixIcon: Icon(LucideIcons.user, size: 18),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: _StyledInputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: Icon(LucideIcons.lock, size: 18),
),
obscureText: true,
),
const SizedBox(height: 20),
_GroupChipSelector(
groups: groups,
selectedGroupId: selectedGroupId,
onChanged: (id) => setDialogState(() => selectedGroupId = id),
),
],
),
onConfirm: () {
final email = emailController.text.trim();
final nick = nickController.text.trim();
final password = passwordController.text.trim();
if (email.isEmpty || nick.isEmpty || password.isEmpty || selectedGroupId == null) {
ToastHelper.error('请填写完整信息');
return false;
}
Navigator.of(ctx).pop();
context.read<AdminProvider>().createUser(
email: email,
nick: nick,
password: password,
groupId: selectedGroupId!,
).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('创建成功');
} else {
ToastHelper.failure('创建失败');
}
}
});
return true;
},
),
),
);
}
void _confirmBatchDelete(BuildContext context, List<int> ids) {
showDialog(
context: context,
builder: (ctx) => _StyledConfirmDialog(
title: '批量删除用户',
message: '确定要删除选中的 ${ids.length} 个用户吗?此操作不可撤销。',
icon: LucideIcons.trash2,
isDestructive: true,
onConfirm: () {
Navigator.of(ctx).pop();
context.read<AdminProvider>().batchDeleteUsers(ids).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('已删除');
} else {
ToastHelper.failure('删除失败');
}
}
});
},
),
);
}
}
/// 用户组 Chip 选择器 — 替代 DropdownButtonFormField
class _GroupChipSelector extends StatelessWidget {
final List<AdminGroupModel> groups;
final int? selectedGroupId;
final ValueChanged<int?> onChanged;
const _GroupChipSelector({
required this.groups,
this.selectedGroupId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('用户组',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w500,
)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: groups.map((group) {
final selected = selectedGroupId == group.id;
return ChoiceChip(
label: Text(group.name),
selected: selected,
onSelected: (_) => onChanged(selected ? null : group.id),
avatar: selected
? null
: CircleAvatar(
radius: 10,
backgroundColor: colorScheme.primaryContainer,
child: Text(
group.name[0],
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: colorScheme.onPrimaryContainer,
),
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
);
}).toList(),
),
if (selectedGroupId == null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'请选择用户组',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error.withValues(alpha: 0.7),
fontSize: 12,
),
),
),
],
);
}
}
class _UserItem extends StatelessWidget {
final AdminUserModel user;
const _UserItem({required this.user});
bool _isAdminGroup(AdminGroupModel group) {
final name = group.name.toLowerCase();
return name == 'admin' || name == '管理员';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Selector<AdminProvider, (bool, bool)>(
selector: (_, p) => (p.isSelectingUsers, p.isUserSelected(user.id)),
builder: (_, data, _) {
final isSelecting = data.$1;
final isSelected = data.$2;
final adminProvider = context.read<AdminProvider>();
return InkWell(
onTap: isSelecting ? () => adminProvider.toggleUserSelection(user.id) : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
if (isSelecting)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Checkbox(
value: isSelected,
onChanged: (_) => adminProvider.toggleUserSelection(user.id),
),
),
UserAvatar(
userId: user.hashId ?? user.id.toString(),
email: user.email,
displayName: user.nick,
radius: 18,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(user.nick,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
),
if (user.group != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: _isAdminGroup(user.group!)
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(user.group!.name,
style: theme.textTheme.labelSmall?.copyWith(
color: _isAdminGroup(user.group!)
? colorScheme.onPrimaryContainer
: theme.hintColor,
fontWeight: FontWeight.w500,
)),
),
],
],
),
const SizedBox(height: 2),
Text(
'${user.email}${user.formattedStorage}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
);
}
}
// ==================== 统一风格对话框组件 ====================
/// 圆角风格对话框 — 与页面 Card 风格统一
class _StyledDialog extends StatelessWidget {
final String title;
final IconData icon;
final Widget child;
final bool Function() onConfirm;
const _StyledDialog({
required this.title,
required this.icon,
required this.child,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 12),
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
],
),
const SizedBox(height: 20),
child,
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => onConfirm(),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('确认'),
),
],
),
],
),
),
);
}
}
/// 圆角确认对话框
class _StyledConfirmDialog extends StatelessWidget {
final String title;
final String message;
final IconData icon;
final bool isDestructive;
final VoidCallback onConfirm;
const _StyledConfirmDialog({
required this.title,
required this.message,
required this.icon,
this.isDestructive = false,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isDestructive
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon,
size: 18,
color: isDestructive
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 12),
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
],
),
const SizedBox(height: 16),
Text(message, style: theme.textTheme.bodyMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: isDestructive ? colorScheme.error : null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('确认'),
),
],
),
],
),
),
);
}
}
/// 统一输入框装饰 — 填充背景 + 圆角
class _StyledInputDecoration extends InputDecoration {
const _StyledInputDecoration({
super.labelText,
super.hintText,
super.prefixIcon,
}) : super(
filled: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide.none,
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
);
}
@@ -0,0 +1,115 @@
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/thick_storage_bar.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// 顶部用户信息卡片
class ProfileInfoCard extends StatelessWidget {
const ProfileInfoCard({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final authProvider = context.watch<AuthProvider>();
final user = authProvider.user;
final displayName = user?.nickname ?? '用户';
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
UserAvatar(
userId: user?.id ?? '',
email: user?.email,
displayName: displayName,
radius: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
'ID: ${user?.id ?? '-'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
if (authProvider.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'管理员',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
const SizedBox(height: 4),
if (user?.email != null)
Text(
user!.email!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
const SizedBox(height: 4),
Text(
'注册于 ${_formatDate(user?.createdAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Consumer<UserSettingProvider>(
builder: (context, userSetting, _) {
final capacity = userSetting.capacity;
return ThickStorageBar(
used: capacity?.used ?? 0,
total: capacity?.total ?? 0,
percentage: capacity?.usagePercentage ?? 0,
);
},
),
],
),
),
);
}
String _formatDate(DateTime? date) {
if (date == null) return '-';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,138 @@
import 'package:cloudreve4_flutter/router/app_router.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class _QuickFunction {
final IconData icon;
final String label;
final String route;
const _QuickFunction({
required this.icon,
required this.label,
required this.route,
});
}
class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key});
static const _functions = [
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
];
static const double _spacing = 12;
static const double _runSpacing = 4;
static const double _minItemWidth = 120;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 14),
child: Row(
children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('快捷功能',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
],
),
),
LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
int perRow = 1;
while (perRow < _functions.length) {
final next = perRow + 1;
final itemWidth = (availableWidth - _spacing * (next - 1)) / next;
if (itemWidth < _minItemWidth) break;
perRow = next;
}
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
return Wrap(
spacing: _spacing,
runSpacing: _runSpacing,
children: _functions.map((fn) {
return SizedBox(
width: itemWidth,
child: _QuickFunctionCard(
icon: fn.icon,
label: fn.label,
onTap: () => Navigator.of(context).pushNamed(fn.route),
),
);
}).toList(),
);
},
),
],
);
}
}
class _QuickFunctionCard extends StatefulWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _QuickFunctionCard({
required this.icon,
required this.label,
required this.onTap,
});
@override
State<_QuickFunctionCard> createState() => _QuickFunctionCardState();
}
class _QuickFunctionCardState extends State<_QuickFunctionCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
color: _hovered
? colorScheme.surfaceContainerHighest
: null,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(12),
onHover: (v) => setState(() => _hovered = v),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: [
Icon(widget.icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Flexible(
child: Text(
widget.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: _hovered ? colorScheme.primary : null,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
/// 粗进度条存储指示器
class ThickStorageBar extends StatelessWidget {
final int used;
final int total;
final double percentage;
const ThickStorageBar({
super.key,
required this.used,
required this.total,
required this.percentage,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: total > 0 ? (used / total).clamp(0.0, 1.0) : 0,
minHeight: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'已使用 ${_formatBytes(used)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
Text(
'${_formatBytes(total)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
],
);
}
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';
}
}
@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import '../../widgets/file_grid_item.dart';
import '../../widgets/file_list_item.dart';
import '../../widgets/gesture_handler_mixin.dart';
import '../../widgets/toast_helper.dart';
/// 回收站页面
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@override
State<RecycleBinPage> createState() => _RecycleBinPageState();
}
class _RecycleBinPageState extends State<RecycleBinPage>
with GestureHandlerMixin {
List<FileModel> _files = [];
Set<String> _selectedFiles = {};
bool _isLoading = false;
String? _errorMessage;
FileViewType _viewType = FileViewType.list;
@override
void initState() {
super.initState();
_loadFiles();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_hasSelection,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop && _hasSelection) {
setState(() {
_selectedFiles.clear();
});
}
},
child: Scaffold(
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
title: const Text('回收站'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
onPressed: () {
if (_hasSelection) {
setState(() {
_selectedFiles.clear();
});
} else {
setState(() {
_selectedFiles =
_files.map((f) => f.path).toSet();
});
}
},
tooltip: _hasSelection ? '取消选择' : '全选',
),
IconButton(
icon: Icon(
_viewType == FileViewType.list
? Icons.grid_view
: Icons.view_list,
),
onPressed: () {
setState(() {
_viewType = _viewType == FileViewType.list
? FileViewType.grid
: FileViewType.list;
});
},
tooltip: _viewType == FileViewType.list ? '网格视图' : '列表视图',
),
],
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _files.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: _refreshFiles,
child: _buildContent(context),
);
}
Widget _buildContent(BuildContext context) {
if (_errorMessage != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadFiles,
child: const Text('重试'),
),
],
),
),
),
],
);
}
if (_files.isEmpty) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.restore_outlined, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'回收站为空',
style: TextStyle(fontSize: 18, color: Colors.grey.shade600),
),
const SizedBox(height: 8),
Text(
'删除的文件会出现在这里',
style: TextStyle(color: Colors.grey.shade500),
),
],
),
),
),
],
);
}
if (_viewType == FileViewType.list) {
return _buildListView(context);
}
return _buildGridView(context);
}
Widget _buildListView(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final isDesktop = screenWidth >= 1000;
return ListView.builder(
itemCount: _files.length,
itemBuilder: (context, index) {
final file = _files[index];
final isSelected = _selectedFiles.contains(file.path);
return FileListItem(
key: ValueKey('trash_file_${file.id}'),
file: file,
isSelected: isSelected,
showCheckbox: _hasSelection,
index: index,
isDesktop: isDesktop,
tapToShowMenu: !_hasSelection,
onTap: () {
if (_hasSelection) {
_toggleSelection(file.path);
}
},
onSelect: () => _toggleSelection(file.path),
onRestore: () => _restoreFile(context, file),
onDelete: () => _deleteFile(context, file),
);
},
);
}
Widget _buildGridView(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final padding = 16.0;
final spacing = 16.0;
final availableWidth = screenWidth - padding * 2;
int crossAxisCount;
if (screenWidth < 400) {
crossAxisCount = 2;
} else if (screenWidth < 600) {
crossAxisCount = 3;
} else if (screenWidth < 900) {
crossAxisCount = 4;
} else {
crossAxisCount = 5;
}
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 140;
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: spacing / 2,
crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio,
),
itemCount: _files.length,
itemBuilder: (context, index) {
final file = _files[index];
final isSelected = _selectedFiles.contains(file.path);
return FileGridItem(
key: ValueKey('trash_file_grid_${file.id}'),
file: file,
isSelected: isSelected,
showCheckbox: _hasSelection,
tapToShowMenu: !_hasSelection,
onTap: () {
if (_hasSelection) {
_toggleSelection(file.path);
}
},
onSelect: () => _toggleSelection(file.path),
onRestore: () => _restoreFile(context, file),
onDelete: () => _deleteFile(context, file),
);
},
);
}
Widget _buildBottomBar(BuildContext context) {
if (_hasSelection) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: [
Text('已选择 ${_selectedFiles.length}'),
const Spacer(),
TextButton.icon(
onPressed: _restoreSelected,
icon: const Icon(Icons.restore),
label: const Text('恢复'),
),
TextButton.icon(
onPressed: _deleteSelected,
icon: const Icon(Icons.delete_forever, color: Colors.red),
label: const Text('彻底删除', style: TextStyle(color: Colors.red)),
),
],
),
),
);
}
return const SizedBox.shrink();
}
bool get _hasSelection => _selectedFiles.isNotEmpty;
void _toggleSelection(String path) {
setState(() {
if (_selectedFiles.contains(path)) {
_selectedFiles.remove(path);
} else {
_selectedFiles.add(path);
}
});
}
Future<void> _refreshFiles() async {
await _loadFiles();
}
Future<void> _loadFiles() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().listTrashFiles(page: 0);
final filesData = response['files'] as List<dynamic>? ?? [];
final files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
setState(() {
_files = files;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
Future<void> _restoreFile(BuildContext context, FileModel file) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('恢复文件'),
content: Text('确定要恢复 "${file.name}" 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('恢复'),
),
],
),
);
if (confirmed == true) {
await _performRestore([file.path]);
}
}
Future<void> _restoreSelected() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('恢复文件'),
content: Text('确定要恢复选中的 ${_selectedFiles.length} 个文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('恢复'),
),
],
),
);
if (confirmed == true) {
await _performRestore(_selectedFiles.toList());
setState(() {
_selectedFiles.clear();
});
}
}
Future<void> _performRestore(List<String> uris) async {
try {
await FileService().restoreFiles(uris: uris);
if (mounted) {
ToastHelper.success('恢复成功');
await _loadFiles();
}
} catch (e) {
if (mounted) {
ToastHelper.failure('恢复失败: $e');
}
}
}
Future<void> _deleteFile(BuildContext context, FileModel file) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('彻底删除'),
content: Text(
'确定要彻底删除 "${file.name}" 吗?\n此操作不可撤销!',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('彻底删除'),
),
],
),
);
if (confirmed == true) {
await _performDelete([file.path]);
}
}
Future<void> _deleteSelected() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('彻底删除'),
content: Text(
'确定要彻底删除选中的 ${_selectedFiles.length} 个文件吗?\n此操作不可撤销!',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('彻底删除'),
),
],
),
);
if (confirmed == true) {
await _performDelete(_selectedFiles.toList());
setState(() {
_selectedFiles.clear();
});
}
}
Future<void> _performDelete(List<String> uris) async {
try {
await FileService().deleteFiles(
uris: uris,
unlink: false,
skipSoftDelete: true,
);
if (mounted) {
ToastHelper.success('删除成功');
await _loadFiles();
}
} catch (e) {
if (mounted) {
ToastHelper.failure('删除失败: $e');
}
}
}
}
enum FileViewType {
list,
grid,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,886 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/cache_settings_model.dart';
import '../../../services/cache_manager_service.dart';
import '../../../services/download_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/download_manager_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/glassmorphism_container.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
import 'log_viewer_page.dart';
/// 应用设置页(缓存、主题、语言)
class AppSettingsPage extends StatefulWidget {
const AppSettingsPage({super.key});
@override
State<AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<AppSettingsPage> {
CacheSettingsModel _cacheSettings = CacheSettingsModel();
bool _isLoading = true;
int? _currentCacheSize;
bool _isCleaning = false;
bool _wifiOnlyEnabled = false;
int _downloadRetries = 3;
int _taskRetentionDays = 7;
bool _gravatarMirrorEnabled = true;
String _gravatarMirrorUrl = 'https://weavatar.com';
String _logFilePath = '';
int? _logFileSize;
String _cacheDirPath = '';
@override
void initState() {
super.initState();
_loadCacheSettings();
_loadWifiOnlySetting();
_loadGravatarMirrorSetting();
_loadLogInfo();
}
Future<void> _loadCacheSettings() async {
try {
final service = CacheManagerService.instance;
await service.initialize();
final settings = service.settings;
if (mounted) {
setState(() {
_cacheSettings = settings;
_isLoading = false;
});
}
Future.delayed(const Duration(milliseconds: 100), () async {
final cacheSize = await service.getCacheSize();
final cacheDir = await service.getCacheDir();
if (mounted) {
setState(() {
_currentCacheSize = cacheSize;
_cacheDirPath = cacheDir.path;
});
}
});
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _saveCacheSettings() async {
final service = CacheManagerService.instance;
await service.saveSettings(_cacheSettings);
if (mounted) ToastHelper.success('设置已保存');
}
Future<void> _loadLogInfo() async {
final path = await AppLogger.logFilePath;
final size = await AppLogger.logFileSize;
if (mounted) {
setState(() {
_logFilePath = path;
_logFileSize = size;
});
}
}
Future<void> _loadWifiOnlySetting() async {
final enabled = await StorageService.instance
.getBool(StorageKeys.downloadWifiOnly) ??
false;
final retries = await StorageService.instance
.getInt(StorageKeys.downloadRetries) ??
3;
final retentionDays = await StorageService.instance
.getInt(StorageKeys.taskRetentionDays) ??
7;
if (mounted) {
setState(() {
_wifiOnlyEnabled = enabled;
_downloadRetries = retries;
_taskRetentionDays = retentionDays;
});
}
}
Future<void> _loadGravatarMirrorSetting() async {
final enabled = await StorageService.instance
.getBool(StorageKeys.gravatarMirrorEnabled) ??
true;
final url = await StorageService.instance
.getString(StorageKeys.gravatarMirrorUrl) ??
'https://weavatar.com';
if (mounted) {
setState(() {
_gravatarMirrorEnabled = enabled;
_gravatarMirrorUrl = url;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('应用设置')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: DesktopConstrained(
child: ListView(
children: [
_buildSection(
title: '外观',
children: [
ListTile(
leading: const Icon(Icons.dark_mode_outlined),
title: const Text('深色模式'),
subtitle: Text(_themeModeLabel(context)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context),
),
ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('主题色'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
backgroundColor: context.watch<ThemeProvider>().seedColor,
radius: 10,
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
onTap: () => _showThemeColorPicker(context),
),
ListTile(
leading: const Icon(Icons.language),
title: const Text('语言'),
subtitle: const Text('跟随系统'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context),
),
],
),
_buildSection(
title: 'Gravatar 镜像',
children: [
SwitchListTile(
title: const Text('启用 Gravatar 镜像'),
subtitle: const Text('国内网络建议启用,加速 Gravatar 头像加载'),
value: _gravatarMirrorEnabled,
onChanged: (value) async {
setState(() => _gravatarMirrorEnabled = value);
await StorageService.instance
.setBool(StorageKeys.gravatarMirrorEnabled, value);
},
),
if (_gravatarMirrorEnabled)
ListTile(
leading: const Icon(Icons.dns_outlined),
title: const Text('镜像地址'),
subtitle: Text(_gravatarMirrorUrl),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showGravatarMirrorUrlDialog(context),
),
],
),
_buildSection(
title: '下载设置',
children: [
SwitchListTile(
title: const Text('仅WiFi下载'),
subtitle: const Text('非WiFi环境下暂停下载,等待WiFi后自动恢复'),
value: _wifiOnlyEnabled,
onChanged: (value) async {
setState(() => _wifiOnlyEnabled = value);
await StorageService.instance
.setBool(StorageKeys.downloadWifiOnly, value);
if (mounted) {
if (!context.mounted) return;
context
.read<DownloadManagerProvider>()
.setWifiOnlyEnabled(value);
}
},
),
ListTile(
title: const Text('重试次数'),
subtitle: Text(_downloadRetries == 0 ? '不重试' : '失败后自动重试 $_downloadRetries'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRetriesDialog(context),
),
ListTile(
title: const Text('任务记录保留'),
subtitle: Text(_taskRetentionDays == -1 ? '永久保留' : '保留 $_taskRetentionDays'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRetentionDaysDialog(context),
),
],
),
_buildSection(
title: '缓存设置',
children: [
ListTile(
title: const Text('最大缓存大小'),
subtitle: Text(_cacheSettings.maxCacheSizeReadable),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showMaxCacheSizeDialog(context),
),
ListTile(
title: const Text('缓存过期时间'),
subtitle: Text(_cacheSettings.cacheExpireDurationReadable),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showCacheExpireDurationDialog(context),
),
SwitchListTile(
title: const Text('自动清理最旧文件'),
subtitle: const Text('当超过最大缓存大小时自动清理'),
value: _cacheSettings.autoCleanOldFiles,
onChanged: (value) {
setState(() {
_cacheSettings = _cacheSettings.copyWith(autoCleanOldFiles: value);
});
_saveCacheSettings();
},
),
],
),
_buildSection(
title: '缓存信息',
children: [
if (_cacheDirPath.isNotEmpty)
ListTile(
title: const Text('缓存目录'),
subtitle: Text(
_cacheDirPath,
style: const TextStyle(fontSize: 11),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: (Platform.isWindows || Platform.isLinux)
? const Icon(Icons.open_in_new, size: 18)
: null,
onTap: (Platform.isWindows || Platform.isLinux)
? _openCacheDir
: null,
),
ListTile(
title: const Text('当前缓存大小'),
subtitle: Text(_formatBytes(_currentCacheSize)),
),
ListTile(
title: const Text('清空缓存'),
leading: const Icon(Icons.delete_outline, color: Colors.red),
trailing: _isCleaning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right),
onTap: _isCleaning ? null : _clearCache,
),
],
),
_buildSection(
title: '日志管理',
children: [
ListTile(
title: const Text('日志文件路径'),
subtitle: Text(
_logFilePath,
style: const TextStyle(fontSize: 11),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
ListTile(
title: const Text('日志文件大小'),
subtitle: Text(_formatBytes(_logFileSize)),
),
if (!Platform.isAndroid)
ListTile(
title: const Text('打开日志目录'),
leading: const Icon(Icons.folder_open),
trailing: const Icon(Icons.chevron_right),
onTap: _openLogFolder,
),
ListTile(
title: const Text('导出日志'),
leading: const Icon(Icons.file_download_outlined),
subtitle: const Text('导出到 Download 目录'),
trailing: const Icon(Icons.chevron_right),
onTap: _exportLog,
),
ListTile(
title: const Text('预览日志'),
leading: const Icon(Icons.visibility_outlined),
trailing: const Icon(Icons.chevron_right),
onTap: _previewLog,
),
ListTile(
title: const Text('清空日志'),
leading: const Icon(Icons.delete_outline, color: Colors.red),
trailing: const Icon(Icons.chevron_right),
onTap: _clearLog,
),
],
),
],
),
),
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: children),
),
],
),
);
}
Future<void> _showThemeColorPicker(BuildContext context) async {
final colors = [
('默认蓝', Colors.blue),
('靛蓝', Colors.indigo),
('紫色', Colors.purple),
('粉红', Colors.pink),
('红色', Colors.red),
('橙色', Colors.orange),
('琥珀', Colors.amber),
('绿色', Colors.green),
('青色', Colors.teal),
('青蓝', Colors.cyan),
];
final currentColor = context.read<ThemeProvider>().seedColor;
final selected = await showDialog<Color>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('选择主题色'),
children: colors.map((c) {
final isSelected = currentColor.toARGB32() == c.$2.toARGB32();
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(c.$2),
child: Row(
children: [
CircleAvatar(backgroundColor: c.$2, radius: 14),
const SizedBox(width: 12),
Expanded(child: Text(c.$1)),
if (isSelected)
Icon(Icons.check, color: Theme.of(ctx).colorScheme.primary),
],
),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
// 立即更新本地主题
await context.read<ThemeProvider>().setSeedColor(selected);
if (!context.mounted) return;
// 同步到服务端
final hex = '#${selected.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
final success = await context.read<UserSettingProvider>().updatePreferredTheme(hex);
if (!mounted) return;
if (success) {
ToastHelper.success('主题色已更新');
} else {
ToastHelper.failure('同步主题色到服务端失败');
}
}
String _themeModeLabel(BuildContext context) {
final mode = context.watch<ThemeProvider>().themeMode;
return switch (mode) {
AppThemeMode.light => '浅色',
AppThemeMode.dark => '深色',
AppThemeMode.system => '跟随系统',
};
}
Future<void> _showThemeModeDialog(BuildContext context) async {
final currentMode = context.read<ThemeProvider>().themeMode;
final options = [
(AppThemeMode.system, '跟随系统', Icons.brightness_auto),
(AppThemeMode.light, '浅色', Icons.light_mode),
(AppThemeMode.dark, '深色', Icons.dark_mode),
];
final selected = await showDialog<AppThemeMode>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('深色模式'),
children: options.map((opt) {
final isSelected = currentMode == opt.$1;
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(opt.$1),
child: Row(
children: [
Icon(opt.$3),
const SizedBox(width: 12),
Expanded(child: Text(opt.$2)),
if (isSelected)
Icon(Icons.check, color: Theme.of(ctx).colorScheme.primary),
],
),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
await context.read<ThemeProvider>().setThemeMode(selected);
}
Future<void> _showLanguageDialog(BuildContext context) async {
final languages = [
('zh-CN', '简体中文'),
('zh-TW', '繁體中文'),
('en-US', 'English'),
('ja-JP', '日本語'),
];
final selected = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('选择语言'),
children: languages.map((l) {
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(l.$1),
child: Text(l.$2),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateLanguage(selected);
if (!mounted) return;
if (success) {
ToastHelper.success('语言偏好已保存');
} else {
ToastHelper.failure('更新语言失败');
}
}
Future<void> _showMaxCacheSizeDialog(BuildContext context) async {
final availableSizes = CacheSettingsModel.availableSizes;
final currentValue = _cacheSettings.maxCacheSize ~/ (1024 * 1024);
final selected = await _showGlassOptionDialog<int>(
context,
title: '最大缓存大小',
icon: LucideIcons.hardDrive,
options: availableSizes.map((size) => (size, '$size MB', currentValue == size)).toList(),
);
if (selected != null && mounted) {
setState(() => _cacheSettings = CacheSettingsModel.fromMB(selected));
_saveCacheSettings();
}
}
Future<void> _showCacheExpireDurationDialog(BuildContext context) async {
final availableDurations = CacheSettingsModel.availableDurations;
final currentValue = _cacheSettings.cacheExpireDuration ~/ (24 * 60 * 60 * 1000);
final selected = await _showGlassOptionDialog<int>(
context,
title: '缓存过期时间',
icon: LucideIcons.timer,
options: availableDurations.map((days) => (days, '$days', currentValue == days)).toList(),
);
if (selected != null && mounted) {
setState(() => _cacheSettings = CacheSettingsModel.fromDays(selected));
_saveCacheSettings();
}
}
Future<void> _showRetriesDialog(BuildContext context) async {
final retriesOptions = [0, 1, 2, 3, 5, 10];
final selected = await _showGlassOptionDialog<int>(
context,
title: '重试次数',
icon: LucideIcons.refreshCw,
subtitle: '下载失败后自动重试的次数',
options: retriesOptions.map((retries) =>
(retries, retries == 0 ? '不重试' : '$retries', _downloadRetries == retries)).toList(),
);
if (selected != null && mounted) {
setState(() => _downloadRetries = selected);
await StorageService.instance
.setInt(StorageKeys.downloadRetries, selected);
}
}
Future<void> _showRetentionDaysDialog(BuildContext context) async {
final options = [
(7, '7 天'),
(15, '15 天'),
(30, '30 天'),
(-1, '永久保留'),
];
final selected = await _showGlassOptionDialog<int>(
context,
title: '任务记录保留时间',
icon: LucideIcons.clock,
subtitle: '超过保留时间的已完成任务将被自动清理',
options: options.map((opt) => (opt.$1, opt.$2, _taskRetentionDays == opt.$1)).toList(),
);
if (selected != null && mounted) {
setState(() => _taskRetentionDays = selected);
await StorageService.instance
.setInt(StorageKeys.taskRetentionDays, selected);
}
}
/// 通用毛玻璃选项选择对话框
Future<T?> _showGlassOptionDialog<T>(
BuildContext context, {
required String title,
required IconData icon,
String? subtitle,
required List<(T, String, bool)> options,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 380.0 : screenWidth - 48.0;
final colorScheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
subtitle,
style: TextStyle(fontSize: 13, color: theme.hintColor),
),
),
),
const Divider(height: 1),
// Options
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: options.length,
itemBuilder: (context, index) {
final (value, label, isSelected) = options[index];
return ListTile(
leading: Icon(
isSelected
? LucideIcons.checkCircle2
: LucideIcons.circle,
size: 20,
color: isSelected
? colorScheme.primary
: theme.hintColor,
),
title: Text(label),
selected: isSelected,
onTap: () => Navigator.of(context).pop(value),
);
},
),
),
],
),
),
),
),
),
);
},
);
}
Future<void> _showGravatarMirrorUrlDialog(BuildContext context) async {
final controller = TextEditingController(text: _gravatarMirrorUrl);
final presets = [
'https://weavatar.com',
'https://gravatar.loli.net',
'https://cdn.v2ex.com/gravatar',
];
final selected = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gravatar 镜像地址'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '镜像地址',
hintText: '例如: https://weavatar.com',
isDense: true,
),
),
const SizedBox(height: 16),
const Text('常用镜像:', style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: presets.map((url) => ActionChip(
label: Text(url, style: const TextStyle(fontSize: 11)),
onPressed: () => controller.text = url,
)).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
child: const Text('确定'),
),
],
),
);
if (selected != null && selected.isNotEmpty && mounted) {
var url = selected;
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
setState(() => _gravatarMirrorUrl = url);
await StorageService.instance
.setString(StorageKeys.gravatarMirrorUrl, url);
}
}
Future<void> _clearCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空缓存'),
content: const Text('确定要清空所有缓存吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: const Text('清空'),
),
],
),
);
if (confirmed == true && mounted) {
setState(() => _isCleaning = true);
try {
final service = CacheManagerService.instance;
await service.clearCache();
final newCacheSize = await service.getCacheSize();
if (mounted) {
setState(() {
_currentCacheSize = newCacheSize;
_isCleaning = false;
});
ToastHelper.success('缓存已清空');
}
} catch (e) {
if (mounted) {
setState(() => _isCleaning = false);
ToastHelper.failure('清空缓存失败: $e');
}
}
}
}
String _formatBytes(int? bytes) {
if (bytes == null) return '未知';
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';
}
Future<void> _openLogFolder() async {
try {
final path = _logFilePath;
if (path.isEmpty) {
ToastHelper.error('日志文件路径未获取');
return;
}
final dir = File(path).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done) {
if (mounted) ToastHelper.error('无法打开目录:${result.message}');
}
} catch (e) {
if (mounted) ToastHelper.error('打开目录失败:$e');
}
}
Future<void> _openCacheDir() async {
try {
if (_cacheDirPath.isEmpty) {
ToastHelper.error('缓存目录路径未获取');
return;
}
final result = await OpenFile.open(_cacheDirPath);
if (result.type != ResultType.done) {
if (mounted) ToastHelper.error('无法打开目录:${result.message}');
}
} catch (e) {
if (mounted) ToastHelper.error('打开目录失败:$e');
}
}
Future<void> _exportLog() async {
try {
final dir = await DownloadService().getDownloadDirectory();
final destPath = await AppLogger.exportLog(dir.path);
if (destPath != null && mounted) {
ToastHelper.success('日志已导出到:$destPath');
} else if (mounted) {
ToastHelper.error('导出失败:日志文件不存在');
}
} catch (e) {
if (mounted) ToastHelper.error('导出日志失败:$e');
}
}
Future<void> _previewLog() async {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LogViewerPage()),
);
}
Future<void> _clearLog() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空日志'),
content: const Text('确定要清空日志文件内容吗?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: const Text('清空'),
),
],
),
);
if (confirmed == true && mounted) {
await AppLogger.clearLog();
await _loadLogInfo();
if (mounted) ToastHelper.success('日志已清空');
}
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../data/models/user_setting_model.dart';
import '../../../services/user_setting_service.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
/// 积分变动历史页
class CreditHistoryPage extends StatefulWidget {
final int currentCredit;
const CreditHistoryPage({super.key, required this.currentCredit});
@override
State<CreditHistoryPage> createState() => _CreditHistoryPageState();
}
class _CreditHistoryPageState extends State<CreditHistoryPage> {
final List<CreditChange> _changes = [];
String? _nextToken;
bool _isLoading = true;
bool _isLoadingMore = false;
bool get _hasMore => _nextToken != null && _nextToken!.isNotEmpty;
@override
void initState() {
super.initState();
_loadChanges();
}
Future<void> _loadChanges() async {
try {
final result = await UserSettingService.instance.getCreditChanges(
nextPageToken: _nextToken,
);
if (mounted) {
setState(() {
_changes.addAll(result.changes);
_nextToken = result.nextToken;
_isLoading = false;
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_isLoadingMore = false;
});
ToastHelper.failure('加载积分记录失败: $e');
}
}
}
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return;
setState(() => _isLoadingMore = true);
await _loadChanges();
}
Future<void> _refresh() async {
setState(() {
_changes.clear();
_nextToken = null;
_isLoading = true;
});
await _loadChanges();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('积分记录')),
body: DesktopConstrained(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _refresh,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'${widget.currentCredit}',
style: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(height: 4),
Text('当前积分', style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
],
),
),
),
if (_changes.isEmpty)
const SliverFillRemaining(
child: Center(child: Text('暂无积分记录', style: TextStyle(color: Colors.grey))),
)
else ...[
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == _changes.length) {
return _hasMore
? Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: _isLoadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: _loadMore,
child: const Text('加载更多'),
),
),
)
: const SizedBox.shrink();
}
final change = _changes[index];
final isPositive = change.diff > 0;
return ListTile(
leading: Icon(
isPositive ? Icons.add_circle_outline : Icons.remove_circle_outline,
color: isPositive ? Colors.green : Colors.red,
),
title: Text(change.reasonLabel),
subtitle: Text(_formatDateTime(change.changedAt)),
trailing: Text(
'${isPositive ? "+" : ""}${change.diff}',
style: TextStyle(
color: isPositive ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
);
},
childCount: _changes.length + 1,
),
),
],
],
),
),
),
);
}
String _formatDateTime(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'
' ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../data/models/user_setting_model.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
/// 文件偏好设置页
class FilePreferencesPage extends StatefulWidget {
const FilePreferencesPage({super.key});
@override
State<FilePreferencesPage> createState() => _FilePreferencesPageState();
}
class _FilePreferencesPageState extends State<FilePreferencesPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserSettingProvider>().loadSettings();
});
}
@override
Widget build(BuildContext context) {
final provider = context.watch<UserSettingProvider>();
final settings = provider.settings;
return Scaffold(
appBar: AppBar(title: const Text('文件偏好')),
body: DesktopConstrained(
child: ListView(
children: [
_buildSection(
title: '版本保留',
children: [
SwitchListTile(
secondary: const Icon(Icons.history),
title: const Text('启用版本保留'),
subtitle: const Text('保留文件的历史版本'),
value: settings?.versionRetentionEnabled ?? false,
onChanged: (value) async {
final success = await context.read<UserSettingProvider>().updateVersionRetention(enabled: value);
if (!mounted) return;
if (!success) ToastHelper.failure('更新失败');
},
),
ListTile(
leading: const Icon(Icons.filter_list),
title: const Text('保留的文件类型'),
subtitle: Text(
settings?.versionRetentionExt == null
? '所有文件类型'
: (settings!.versionRetentionExt!.isEmpty
? '所有文件类型'
: settings.versionRetentionExt!.join(', ')),
),
trailing: const Icon(Icons.chevron_right),
enabled: settings?.versionRetentionEnabled ?? false,
onTap: () => _showExtEditor(context, settings),
),
ListTile(
leading: const Icon(Icons.numbers),
title: const Text('最大保留版本数'),
subtitle: Text(
(settings?.versionRetentionMax ?? 0) == 0
? '无限制'
: '${settings!.versionRetentionMax} 个版本',
),
trailing: const Icon(Icons.chevron_right),
enabled: settings?.versionRetentionEnabled ?? false,
onTap: () => _showMaxVersionsDialog(context, settings),
),
],
),
_buildSection(
title: '视图与同步',
children: [
SwitchListTile(
secondary: const Icon(Icons.sync_disabled),
title: const Text('禁用视图同步'),
subtitle: const Text('关闭后视图设置不会跨设备同步'),
value: settings?.disableViewSync ?? false,
onChanged: (value) async {
final success = await context.read<UserSettingProvider>().updateViewSync(value);
if (!mounted) return;
if (!success) ToastHelper.failure('更新失败');
},
),
],
),
_buildSection(
title: '分享',
children: [
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('个人主页分享链接可见性'),
subtitle: Text(_shareVisibilityLabel(settings?.shareLinksInProfile ?? '')),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showShareVisibilityDialog(context, settings),
),
],
),
],
),
),
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: children),
),
],
),
);
}
String _shareVisibilityLabel(String value) {
switch (value) {
case 'all_share':
return '所有分享链接';
case 'hide_share':
return '隐藏分享链接';
default:
return '仅公开分享';
}
}
Future<void> _showShareVisibilityDialog(BuildContext context, UserSettingModel? settings) async {
final currentValue = settings?.shareLinksInProfile ?? '';
final options = [
('', '仅公开分享', '仅在个人主页显示公开分享'),
('all_share', '所有分享链接', '在个人主页显示所有分享'),
('hide_share', '隐藏分享链接', '不在个人主页显示任何分享'),
];
final selected = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('分享链接可见性'),
children: options
.map((opt) => SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(opt.$1),
child: Row(
children: [
Icon(
currentValue == opt.$1 ? Icons.radio_button_checked : Icons.radio_button_unchecked,
color: Theme.of(ctx).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(opt.$2, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(opt.$3, style: Theme.of(ctx).textTheme.bodySmall),
],
),
),
],
),
))
.toList(),
),
);
if (selected == null || !mounted) return;
if (selected == currentValue) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateShareLinksInProfile(selected);
if (!mounted) return;
if (success) {
ToastHelper.success('已更新');
} else {
ToastHelper.failure('更新失败');
}
}
Future<void> _showMaxVersionsDialog(BuildContext context, UserSettingModel? settings) async {
final controller = TextEditingController(
text: (settings?.versionRetentionMax ?? 0) == 0 ? '' : '${settings!.versionRetentionMax}',
);
final isUnlimited = (settings?.versionRetentionMax ?? 0) == 0;
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('最大保留版本数'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
title: const Text('无限制'),
value: isUnlimited,
onChanged: (v) => setDialogState(() {}),
),
if (!isUnlimited)
TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '版本数',
hintText: '输入最大保留版本数',
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(null), child: const Text('取消')),
FilledButton(
onPressed: () {
final unlimited = isUnlimited;
final max = unlimited ? 0 : (int.tryParse(controller.text) ?? 0);
Navigator.of(ctx).pop({'unlimited': unlimited, 'max': max});
},
child: const Text('确定'),
),
],
),
),
);
if (result == null || !mounted) return;
final max = result['max'] as int;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateVersionRetention(max: max);
if (!mounted) return;
if (success) {
ToastHelper.success('已更新');
} else {
ToastHelper.failure('更新失败');
}
}
Future<void> _showExtEditor(BuildContext context, UserSettingModel? settings) async {
final exts = settings?.versionRetentionExt ?? [];
final controller = TextEditingController(text: exts.join(', '));
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('保留的文件类型'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('输入文件扩展名,用逗号分隔。留空表示所有类型。'),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '扩展名',
hintText: '.doc, .pdf, .xlsx',
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('确定'),
),
],
),
);
if (confirmed != true || !mounted) return;
final text = controller.text.trim();
List<String>? newExts;
if (text.isEmpty) {
newExts = null; // null 表示所有类型
} else {
newExts = text.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
}
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateVersionRetention(ext: newExts);
if (!mounted) return;
if (success) {
ToastHelper.success('已更新');
} else {
ToastHelper.failure('更新失败');
}
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../../core/utils/app_logger.dart';
import '../../widgets/toast_helper.dart';
/// 日志预览页面
class LogViewerPage extends StatefulWidget {
const LogViewerPage({super.key});
@override
State<LogViewerPage> createState() => _LogViewerPageState();
}
class _LogViewerPageState extends State<LogViewerPage> {
String _logContent = '';
bool _isLoading = true;
final bool _isAutoScroll = true;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadLog();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _loadLog() async {
setState(() => _isLoading = true);
try {
final content = await AppLogger.readLog(maxLines: 1000);
if (mounted) {
setState(() {
_logContent = content;
_isLoading = false;
});
if (_isAutoScroll) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent);
}
});
}
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ToastHelper.error('读取日志失败:$e');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('日志预览'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLog,
tooltip: '刷新',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _logContent.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.description_outlined,
size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text('暂无日志', style: TextStyle(color: theme.hintColor)),
],
),
)
: Container(
color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
child: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(12),
child: SelectableText(
_logContent,
style: TextStyle(
fontFamily: 'SourceCodePro',
fontSize: 13,
height: 1.5,
color: isDark ? Colors.grey[300] : Colors.grey[900],
),
),
),
),
),
);
}
}
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import '../../../data/models/user_model.dart';
import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
import '../../widgets/user_avatar.dart';
/// 个人资料编辑页
class ProfileEditPage extends StatefulWidget {
const ProfileEditPage({super.key});
@override
State<ProfileEditPage> createState() => _ProfileEditPageState();
}
class _ProfileEditPageState extends State<ProfileEditPage> {
bool _isUploadingAvatar = false;
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final user = auth.user;
return Scaffold(
appBar: AppBar(title: const Text('个人资料')),
body: DesktopConstrained(
child: ListView(
children: [
const SizedBox(height: 24),
_buildAvatarSection(context, user),
const SizedBox(height: 16),
_buildInfoTile(
context,
icon: Icons.badge_outlined,
title: '昵称',
value: user?.nickname ?? '',
onTap: () => _showEditNickDialog(context, user),
),
_buildInfoTile(
context,
icon: Icons.email_outlined,
title: '邮箱',
value: user?.email ?? '',
onTap: null, // 邮箱不可修改
),
_buildInfoTile(
context,
icon: Icons.group_outlined,
title: '用户组',
value: user?.group?.name ?? '',
onTap: null,
),
_buildInfoTile(
context,
icon: Icons.calendar_today_outlined,
title: '注册时间',
value: _formatDate(user?.createdAt),
onTap: null,
),
],
),
),
);
}
Widget _buildAvatarSection(BuildContext context, UserModel? user) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Stack(
children: [
_buildAvatar(context, user, 80),
Positioned(
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
child: _isUploadingAvatar
? Padding(
padding: const EdgeInsets.all(6),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
),
)
: IconButton(
icon: Icon(Icons.camera_alt, size: 16, color: colorScheme.onPrimary),
onPressed: _showAvatarOptions,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
),
),
],
),
);
}
Widget _buildAvatar(BuildContext context, UserModel? user, double size) {
return UserAvatar(
userId: user?.id ?? '',
email: user?.email,
displayName: user?.nickname ?? '用户',
radius: size / 2,
);
}
Widget _buildInfoTile(
BuildContext context, {
required IconData icon,
required String title,
required String value,
VoidCallback? onTap,
}) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(value, style: Theme.of(context).textTheme.bodyMedium),
trailing: onTap != null ? const Icon(Icons.chevron_right) : null,
onTap: onTap,
);
}
Future<void> _showAvatarOptions() async {
final result = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('从相册选择'),
onTap: () => Navigator.of(ctx).pop('gallery'),
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('拍照'),
onTap: () => Navigator.of(ctx).pop('camera'),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('使用 Gravatar'),
subtitle: const Text('根据邮箱自动生成头像'),
onTap: () => Navigator.of(ctx).pop('gravatar'),
),
],
),
),
);
if (result == null || !mounted) return;
if (result == 'gravatar') {
await _resetToGravatar();
} else {
final source = result == 'camera' ? ImageSource.camera : ImageSource.gallery;
await _pickAndUploadAvatar(source);
}
}
Future<void> _pickAndUploadAvatar(ImageSource source) async {
try {
final picker = ImagePicker();
final image = await picker.pickImage(source: source, maxWidth: 512, maxHeight: 512);
if (image == null || !mounted) return;
setState(() => _isUploadingAvatar = true);
final bytes = await image.readAsBytes();
final service = UserSettingService.instance;
await service.updateAvatar(bytes);
if (!mounted) return;
// 刷新用户信息
await context.read<AuthProvider>().refreshUser();
// 清除头像缓存,使其他页面的 UserAvatar 刷新
if (!mounted) return;
final userId = context.read<AuthProvider>().user?.id ?? '';
await UserAvatar.evictCache(userId);
if (mounted) {
setState(() => _isUploadingAvatar = false);
ToastHelper.success('头像已更新');
}
} catch (e) {
if (mounted) {
setState(() => _isUploadingAvatar = false);
ToastHelper.failure('上传头像失败: $e');
}
}
}
Future<void> _resetToGravatar() async {
try {
setState(() => _isUploadingAvatar = true);
final service = UserSettingService.instance;
await service.updateAvatar(null);
if (!mounted) return;
await context.read<AuthProvider>().refreshUser();
if (!mounted) return;
// 清除头像缓存,使其他页面的 UserAvatar 刷新
final userId = context.read<AuthProvider>().user?.id ?? '';
await UserAvatar.evictCache(userId);
if (mounted) {
setState(() => _isUploadingAvatar = false);
ToastHelper.success('已切换为 Gravatar');
}
} catch (e) {
if (mounted) {
setState(() => _isUploadingAvatar = false);
ToastHelper.failure('操作失败: $e');
}
}
}
Future<void> _showEditNickDialog(BuildContext context, UserModel? user) async {
final controller = TextEditingController(text: user?.nickname ?? '');
final formKey = GlobalKey<FormState>();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改昵称'),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: '昵称',
hintText: '请输入新昵称',
),
validator: (v) {
if (v == null || v.trim().isEmpty) return '昵称不能为空';
if (v.trim().length > 255) return '昵称不能超过255个字符';
return null;
},
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.of(ctx).pop(true);
}
},
child: const Text('确定'),
),
],
),
);
if (confirmed != true || !mounted) return;
final newNick = controller.text.trim();
if (newNick == user?.nickname) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateNick(newNick);
if (!mounted) return;
if (!context.mounted) return;
if (success) {
// 同步刷新 AuthProvider 中的用户信息
await context.read<AuthProvider>().refreshUser();
ToastHelper.success('昵称已修改');
} else {
ToastHelper.failure('修改昵称失败');
}
}
String _formatDate(DateTime? date) {
if (date == null) return '';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,358 @@
import 'package:cloudreve4_flutter/core/constants/quick_access_defaults.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class QuickAccessSettingsPage extends StatefulWidget {
const QuickAccessSettingsPage({super.key});
@override
State<QuickAccessSettingsPage> createState() => _QuickAccessSettingsPageState();
}
class _QuickAccessSettingsPageState extends State<QuickAccessSettingsPage> {
List<QuickAccessConfig> _items = [];
bool _isLoaded = false;
@override
void initState() {
super.initState();
_loadConfig();
}
Future<void> _loadConfig() async {
var saved = await StorageService.instance.getString(QuickAccessConfig.storageKey);
if (saved != null && saved.isNotEmpty) {
try {
if (mounted) setState(() { _items = QuickAccessConfig.parseSaved(saved); _isLoaded = true; });
return;
} catch (_) {}
}
// 迁移 v1
final v1 = await StorageService.instance.getString('quick_access_shortcuts');
if (v1 != null && v1.isNotEmpty) {
final migrated = QuickAccessConfig.migrateV1(v1);
if (mounted) {
setState(() { _items = migrated; _isLoaded = true; });
await _save();
}
return;
}
if (mounted) setState(() { _items = List.from(QuickAccessConfig.defaults); _isLoaded = true; });
}
Future<void> _save() async {
await StorageService.instance.setString(
QuickAccessConfig.storageKey,
QuickAccessConfig.serialize(_items),
);
}
Future<void> _editItem(int index) async {
final item = _items[index];
final labelController = TextEditingController(text: item.label);
final pathController = TextEditingController(text: item.path);
final result = await showDialog<_EditResult>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('编辑快捷入口'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(_EditResult(labelController.text, pathController.text)),
child: const Text('确定'),
),
],
),
);
if (result != null) {
setState(() {
_items[index] = item.copyWith(
label: result.label.isNotEmpty ? result.label : item.label,
path: result.path.isNotEmpty ? result.path : item.path,
);
});
_save();
ToastHelper.success('快捷入口已更新');
}
}
Future<void> _addItem() async {
final labelController = TextEditingController();
final pathController = TextEditingController();
IconData selectedIcon = LucideIcons.folder;
Color selectedColor = QuickAccessConfig.colorPool[0];
final result = await showDialog<_AddResult>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialogState) {
return AlertDialog(
title: const Text('新增快捷入口'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: labelController,
decoration: const InputDecoration(labelText: '名称', hintText: '例如: 图片'),
),
const SizedBox(height: 12),
TextField(
controller: pathController,
decoration: const InputDecoration(labelText: '目录路径', hintText: '例如: /Images'),
),
const SizedBox(height: 16),
Text('图标', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: QuickAccessConfig.iconPool.map((icon) {
final isSelected = icon.codePoint == selectedIcon.codePoint;
return GestureDetector(
onTap: () => setDialogState(() => selectedIcon = icon),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected ? Theme.of(ctx).colorScheme.primary.withValues(alpha: 0.15) : null,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: Theme.of(ctx).colorScheme.primary, width: 2)
: Border.all(color: Theme.of(ctx).dividerColor),
),
child: Icon(icon, size: 20, color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor),
),
);
}).toList(),
),
const SizedBox(height: 16),
Text('颜色', style: Theme.of(ctx).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: QuickAccessConfig.colorPool.map((color) {
final isSelected = color.toARGB32() == selectedColor.toARGB32();
return GestureDetector(
onTap: () => setDialogState(() => selectedColor = color),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: color.darken(0.2), width: 3)
: null,
),
child: isSelected
? Icon(LucideIcons.check, size: 18, color: color.darken(0.3))
: null,
),
);
}).toList(),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(_AddResult(
labelController.text,
pathController.text,
selectedIcon,
selectedColor,
)),
child: const Text('添加'),
),
],
);
},
);
},
);
if (result != null && result.label.isNotEmpty && result.path.isNotEmpty) {
setState(() {
_items.add(QuickAccessConfig(
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
label: result.label,
icon: result.icon,
path: result.path,
color: result.color,
));
});
_save();
ToastHelper.success('快捷入口已添加');
}
}
void _moveItem(int from, int to) {
if (from < 0 || from >= _items.length || to < 0 || to >= _items.length || from == to) return;
setState(() {
final item = _items.removeAt(from);
_items.insert(to, item);
});
_save();
}
void _deleteItem(int index) {
if (_items[index].isDefault) return;
setState(() {
_items.removeAt(index);
});
_save();
ToastHelper.success('快捷入口已删除');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('快捷入口')),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'自定义概览页中显示的快捷目录入口。默认入口不可删除,但可编辑路径和调整顺序。',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
),
if (_isLoaded)
...List.generate(_items.length, (index) {
final item = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, size: 20, color: item.color.darken(0.3)),
),
title: Row(
children: [
Text(item.label),
if (item.isDefault) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text('默认', style: TextStyle(fontSize: 10, color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
),
],
],
),
subtitle: Text(item.path, style: TextStyle(color: theme.hintColor, fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 上移
IconButton(
icon: Icon(LucideIcons.chevronUp, size: 18),
onPressed: index > 0 ? () => _moveItem(index, index - 1) : null,
tooltip: '上移',
visualDensity: VisualDensity.compact,
),
// 下移
IconButton(
icon: Icon(LucideIcons.chevronDown, size: 18),
onPressed: index < _items.length - 1 ? () => _moveItem(index, index + 1) : null,
tooltip: '下移',
visualDensity: VisualDensity.compact,
),
// 编辑
IconButton(
icon: Icon(LucideIcons.pencil, size: 16),
onPressed: () => _editItem(index),
tooltip: '编辑',
visualDensity: VisualDensity.compact,
),
// 删除(默认不可删)
if (!item.isDefault)
IconButton(
icon: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.error),
onPressed: () => _deleteItem(index),
tooltip: '删除',
visualDensity: VisualDensity.compact,
),
],
),
),
);
}),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('新增快捷入口'),
onPressed: _addItem,
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
icon: const Icon(LucideIcons.rotateCcw, size: 16),
label: const Text('恢复默认'),
onPressed: () {
setState(() { _items = List.from(QuickAccessConfig.defaults); });
_save();
ToastHelper.success('已恢复默认设置');
},
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
}
class _EditResult {
final String label;
final String path;
_EditResult(this.label, this.path);
}
class _AddResult {
final String label;
final String path;
final IconData icon;
final Color color;
_AddResult(this.label, this.path, this.icon, this.color);
}
@@ -0,0 +1,601 @@
import 'dart:convert';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../data/models/user_setting_model.dart';
import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
/// 安全设置页
class SecuritySettingsPage extends StatefulWidget {
const SecuritySettingsPage({super.key});
@override
State<SecuritySettingsPage> createState() => _SecuritySettingsPageState();
}
class _SecuritySettingsPageState extends State<SecuritySettingsPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserSettingProvider>().loadSettings();
});
}
@override
Widget build(BuildContext context) {
final provider = context.watch<UserSettingProvider>();
final settings = provider.settings;
return Scaffold(
appBar: AppBar(title: const Text('安全设置')),
body: DesktopConstrained(
child: ListView(
children: [
_buildSection(
title: '密码',
children: [
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('修改密码'),
subtitle: Text(settings?.passwordless == true ? '当前使用无密码登录' : '修改账户密码'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showChangePasswordDialog(context),
),
],
),
_buildSection(
title: '两步验证',
children: [
SwitchListTile(
secondary: Icon(
settings?.twoFaEnabled == true ? Icons.shield : Icons.shield_outlined,
),
title: const Text('两步验证 (2FA)'),
subtitle: Text(
settings?.twoFaEnabled == true ? '已启用 — 登录时需要验证码' : '未启用',
),
value: settings?.twoFaEnabled ?? false,
onChanged: (value) {
if (value) {
_showEnable2FADialog(context);
} else {
_showDisable2FADialog(context);
}
},
),
],
),
_buildSection(
title: 'Passkey',
children: [
ListTile(
leading: const Icon(Icons.vpn_key_outlined),
title: Text('Passkey (${settings?.passkeys.length ?? 0})'),
subtitle: settings?.passkeys.isEmpty ?? true
? const Text('未注册任何 Passkey')
: Text('已注册 ${settings!.passkeys.length} 个 Passkey'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showPasskeyList(context, settings),
),
],
),
_buildSection(
title: '已关联账号',
children: _buildOpenIdTiles(context, settings),
),
_buildSection(
title: '已授权应用',
children: _buildOAuthGrantTiles(context, settings),
),
if (settings?.loginActivity.isNotEmpty ?? false)
_buildSection(
title: '登录活动',
children: _buildLoginActivityTiles(settings!),
),
],
),
),
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: children),
),
],
),
);
}
// ---- 修改密码 ----
Future<void> _showChangePasswordDialog(BuildContext context) async {
final currentCtrl = TextEditingController();
final newCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
final formKey = GlobalKey<FormState>();
bool obscureCurrent = true;
bool obscureNew = true;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('修改密码'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: currentCtrl,
obscureText: obscureCurrent,
decoration: InputDecoration(
labelText: '当前密码',
suffixIcon: IconButton(
icon: Icon(obscureCurrent ? Icons.visibility_off : Icons.visibility),
onPressed: () => setDialogState(() => obscureCurrent = !obscureCurrent),
),
),
validator: (v) => (v == null || v.isEmpty) ? '请输入当前密码' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: newCtrl,
obscureText: obscureNew,
decoration: InputDecoration(
labelText: '新密码',
suffixIcon: IconButton(
icon: Icon(obscureNew ? Icons.visibility_off : Icons.visibility),
onPressed: () => setDialogState(() => obscureNew = !obscureNew),
),
),
validator: (v) {
if (v == null || v.isEmpty) return '请输入新密码';
if (v.length < 6) return '密码至少6个字符';
if (v.length > 128) return '密码不能超过128个字符';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: confirmCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: '确认新密码'),
validator: (v) {
if (v != newCtrl.text) return '两次输入的密码不一致';
return null;
},
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.of(ctx).pop(true);
}
},
child: const Text('确定'),
),
],
),
),
);
if (confirmed != true || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().changePassword(
currentPassword: currentCtrl.text,
newPassword: newCtrl.text,
);
if (!mounted) return;
if (success) {
ToastHelper.success('密码已修改');
} else {
ToastHelper.failure('修改密码失败,请检查当前密码是否正确');
}
}
// ---- 启用2FA ----
Future<void> _showEnable2FADialog(BuildContext context) async {
final codeCtrl = TextEditingController();
String secret = '';
try {
// 先获取 TOTP secret
final secretJsonString = await context.read<UserSettingProvider>().prepare2FA();
final Map<String, dynamic> secretMap = jsonDecode(secretJsonString!);
secret = secretMap['data'];
} catch (e) {
secret = e.toString();
}
AppLogger.d("2FA API Response --> $secret");
if (secret.isEmpty || !mounted) {
ToastHelper.failure('获取2FA密钥失败');
return;
}
if (!context.mounted) return;
final auth = context.read<AuthProvider>();
final userEmail = auth.user?.email ?? 'user';
final otpAuthUri = 'otpauth://totp/Cloudreve:$userEmail?secret=$secret&issuer=Cloudreve';
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('启用两步验证'),
content: SizedBox(
width: MediaQuery.of(ctx).size.width >= 1000 ? MediaQuery.of(ctx).size.width * 0.4 : MediaQuery.of(ctx).size.width * 0.8,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text('1. 使用验证器应用扫描二维码:'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: QrImageView(
data: otpAuthUri,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 12),
const Text('或手动输入密钥:'),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
textAlign: TextAlign.center,
secret,
style: const TextStyle(fontFamily: 'SourceCodePro', fontSize: 13),
),
),
const SizedBox(height: 16),
const Text('2. 输入验证器显示的6位验证码:'),
const SizedBox(height: 8),
TextField(
controller: codeCtrl,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
autofocus: true,
decoration: const InputDecoration(
labelText: '验证码',
counterText: '',
),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
FilledButton(
onPressed: () async {
final code = codeCtrl.text.trim();
if (code.length != 6) {
ToastHelper.warning('请输入6位验证码');
return;
}
Navigator.of(ctx).pop();
final success = await context.read<UserSettingProvider>().enable2FA(code);
if (!mounted) return;
if (success) {
ToastHelper.success('两步验证已启用');
} else {
ToastHelper.failure('启用失败,请检查验证码是否正确');
}
},
child: const Text('启用'),
),
],
),
);
}
// ---- 禁用2FA ----
Future<void> _showDisable2FADialog(BuildContext context) async {
final codeCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('禁用两步验证'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('请输入当前验证器显示的6位验证码以确认禁用:'),
const SizedBox(height: 12),
TextField(
controller: codeCtrl,
keyboardType: TextInputType.number,
maxLength: 6,
autofocus: true,
decoration: const InputDecoration(labelText: '验证码', counterText: ''),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('禁用'),
),
],
),
);
if (confirmed != true || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().disable2FA(codeCtrl.text.trim());
if (!mounted) return;
if (success) {
ToastHelper.success('两步验证已禁用');
} else {
ToastHelper.failure('禁用失败,请检查验证码是否正确');
}
}
// ---- Passkey 列表 ----
void _showPasskeyList(BuildContext context, UserSettingModel? settings) {
final passkeys = settings?.passkeys ?? [];
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.4,
minChildSize: 0.2,
maxChildSize: 0.7,
expand: false,
builder: (ctx, controller) => ListView(
controller: controller,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text('Passkey 管理', style: Theme.of(ctx).textTheme.titleMedium),
),
if (passkeys.isEmpty)
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: Text('暂无已注册的 Passkey', style: TextStyle(color: Colors.grey))),
)
else
...passkeys.map((pk) => ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(pk.name),
subtitle: Text('创建于 ${_formatDate(pk.createdAt)}'
'${pk.usedAt != null ? ' · 最后使用 ${_formatDate(pk.usedAt!)}' : ''}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _deletePasskey(ctx, pk),
),
)),
// 注册新Passkey暂不实现,Flutter端WebAuthn支持有限
// 后续批次实现
],
),
),
);
}
Future<void> _deletePasskey(BuildContext context, PasskeyModel passkey) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除 Passkey'),
content: Text('确定要删除 "${passkey.name}" 吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await UserSettingService.instance.deletePasskey(passkey.id);
if (!context.mounted) return;
await context.read<UserSettingProvider>().loadSettings();
if (mounted) ToastHelper.success('Passkey 已删除');
} catch (e) {
if (mounted) ToastHelper.failure('删除失败: $e');
}
}
// ---- 已关联账号 ----
List<Widget> _buildOpenIdTiles(BuildContext context, UserSettingModel? settings) {
final openIds = settings?.openId ?? [];
if (openIds.isEmpty) {
return [
ListTile(
leading: const Icon(Icons.link_off),
title: const Text('暂无关联账号'),
subtitle: const Text('未关联任何第三方账号'),
),
];
}
return openIds
.map((oid) => ListTile(
leading: Icon(_openIdIcon(oid.provider)),
title: Text(oid.providerName),
subtitle: Text('关联于 ${_formatDate(oid.linkedAt)}'),
trailing: IconButton(
icon: const Icon(Icons.link_off, color: Colors.red),
onPressed: () => _unlinkOpenId(context, oid),
),
))
.toList();
}
Future<void> _unlinkOpenId(BuildContext context, OpenIdProvider oid) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('解绑账号'),
content: Text('确定要解绑 ${oid.providerName} 吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('解绑'),
),
],
),
);
if (confirmed != true || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().unlinkOpenId(oid.provider);
if (!mounted) return;
if (success) {
ToastHelper.success('已解绑 ${oid.providerName}');
} else {
ToastHelper.failure('解绑失败');
}
}
IconData _openIdIcon(int provider) {
switch (provider) {
case 1:
return Icons.chat_bubble; // QQ
default:
return Icons.login;
}
}
// ---- OAuth 授权 ----
List<Widget> _buildOAuthGrantTiles(BuildContext context, UserSettingModel? settings) {
final grants = settings?.oauthGrants ?? [];
if (grants.isEmpty) {
return [
ListTile(
leading: const Icon(Icons.apps_outage),
title: const Text('暂无授权应用'),
subtitle: const Text('未授权任何第三方应用'),
),
];
}
return grants
.map((grant) => ListTile(
leading: grant.clientLogo != null
? CircleAvatar(backgroundImage: NetworkImage(grant.clientLogo!))
: const CircleAvatar(child: Icon(Icons.apps)),
title: Text(grant.clientName),
subtitle: Text(grant.lastUsedAt != null
? '最后使用 ${_formatDate(grant.lastUsedAt!)}'
: '权限: ${grant.scopes.join(", ")}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _revokeOAuth(context, grant),
),
))
.toList();
}
Future<void> _revokeOAuth(BuildContext context, OAuthGrant grant) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('撤销授权'),
content: Text('确定要撤销 "${grant.clientName}" 的授权吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('撤销'),
),
],
),
);
if (confirmed != true || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().revokeOAuthGrant(grant.clientId);
if (!mounted) return;
if (success) {
ToastHelper.success('已撤销 ${grant.clientName} 的授权');
} else {
ToastHelper.failure('撤销授权失败');
}
}
// ---- 登录活动 ----
List<Widget> _buildLoginActivityTiles(UserSettingModel settings) {
final activities = settings.loginActivity;
if (activities.isEmpty) {
return [
const ListTile(
leading: Icon(Icons.history),
title: Text('暂无登录记录'),
),
];
}
return activities.map((activity) {
final icon = activity.success
? Icons.check_circle_outline
: Icons.cancel_outlined;
final iconColor = activity.success ? Colors.green : Colors.red;
return ListTile(
leading: Icon(icon, color: iconColor),
title: Text(activity.loginMethodName),
subtitle: Text(
'${_formatDate(activity.createdAt)}'
'\n${activity.os} · ${activity.browser}'
'${activity.ip.isNotEmpty ? " · ${activity.ip}" : ""}',
),
isThreeLine: true,
);
}).toList();
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,534 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../data/models/user_model.dart';
import '../../../data/models/user_setting_model.dart';
import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/user_avatar.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
import 'profile_edit_page.dart';
import 'security_settings_page.dart';
import 'file_preferences_page.dart';
import 'app_settings_page.dart';
import 'credit_history_page.dart';
import 'quick_access_settings_page.dart';
/// 设置主页
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
String _appVersion = '';
@override
void initState() {
super.initState();
_loadAppVersion();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserSettingProvider>().loadAll();
});
}
Future<void> _loadAppVersion() async {
try {
final info = await PackageInfo.fromPlatform();
if (mounted) setState(() => _appVersion = info.version);
} catch (_) {}
}
Future<void> _refresh() async {
await context.read<UserSettingProvider>().loadAll();
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final user = auth.user;
final settingProvider = context.watch<UserSettingProvider>();
final settings = settingProvider.settings;
final capacity = settingProvider.capacity;
final isLoading = settingProvider.isLoading;
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
actions: [
if (isLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
tooltip: '刷新',
),
],
),
body: DesktopConstrained(
child: ListView(
children: [
_buildProfileCard(context, user, settings, capacity),
const SizedBox(height: 8),
_buildSection(
title: '账户与安全',
children: [
_SettingsTile(
icon: Icons.person_outline,
title: '个人资料',
subtitle: '修改昵称、头像',
onTap: () => _navigateTo(context, const ProfileEditPage()),
),
_SettingsTile(
icon: Icons.security_outlined,
title: '安全设置',
subtitle: _securitySubtitle(settings),
onTap: () => _navigateTo(context, const SecuritySettingsPage()),
),
],
),
_buildSection(
title: '偏好',
children: [
_SettingsTile(
icon: Icons.apps_outlined,
title: '快捷入口',
subtitle: '自定义概览页快捷目录',
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()),
),
_SettingsTile(
icon: Icons.folder_outlined,
title: '文件偏好',
subtitle: '版本保留、视图同步、分享可见性',
onTap: () => _navigateTo(context, const FilePreferencesPage()),
),
_SettingsTile(
icon: Icons.tune,
title: '应用设置',
subtitle: '缓存、主题、语言',
onTap: () => _navigateTo(context, const AppSettingsPage()),
),
],
),
// 财务区域(有数据时才显示)
if (settings != null) ..._buildProSections(context, settings),
_buildSection(
title: '关于',
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('应用名称'),
subtitle: const Text('公云存储'),
),
ListTile(
leading: const Icon(Icons.tag),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
),
ListTile(
leading: const Icon(Icons.copyright),
title: const Text('License'),
subtitle: const Text('AGPL-3.0'),
),
ListTile(
leading: const Icon(Icons.code),
title: const Text('二次开发'),
subtitle: const Text('gongyun_app'),
trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () {
launchUrl(
Uri.parse('https://git.saont.net/gongyun/app'),
mode: LaunchMode.externalApplication,
);
},
),
ListTile(
leading: const Icon(Icons.code),
title: const Text('基于'),
subtitle: const Text('cloudreve4_flutter'),
trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () {
launchUrl(
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'),
mode: LaunchMode.externalApplication,
);
},
),
],
),
const SizedBox(height: 24),
_buildLogoutButton(context, auth),
const SizedBox(height: 32),
],
),
),
);
}
/// 财务区域(存储包、积分、会员)
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) {
final sections = <Widget>[];
final hasStoragePacks = settings.storagePacks.isNotEmpty;
final hasCredit = settings.credit > 0;
final hasMembership = settings.groupExpires != null;
if (hasStoragePacks || hasCredit || hasMembership) {
final children = <Widget>[];
if (hasMembership) {
children.add(ListTile(
leading: const Icon(Icons.workspace_premium_outlined),
title: const Text('会员'),
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
trailing: TextButton(
onPressed: () => _cancelMembership(context),
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
),
));
}
if (hasStoragePacks) {
children.add(ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text('存储包 (${settings.storagePacks.length})'),
subtitle: Text(_storagePackSummary(settings.storagePacks)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showStoragePacks(context, settings.storagePacks),
));
}
if (hasCredit) {
children.add(ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('积分'),
subtitle: Text('${settings.credit} 积分'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)),
));
}
sections.add(_buildSection(title: '财务', children: children));
}
return sections;
}
Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: () => _navigateTo(context, const ProfileEditPage()),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
_buildAvatar(context, user, 56),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user?.nickname ?? '未登录',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(
user?.email ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
if (user?.group != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
user!.group!.name,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
),
],
],
),
),
const Icon(Icons.chevron_right),
],
),
if (capacity != null) ...[
const SizedBox(height: 16),
_buildStorageBar(context, capacity),
],
],
),
),
),
);
}
Widget _buildAvatar(BuildContext context, UserModel? user, double size) {
return UserAvatar(
userId: user?.id ?? '',
email: user?.email,
displayName: user?.nickname ?? '用户',
radius: size / 2,
);
}
Widget _buildStorageBar(BuildContext context, UserCapacityModel capacity) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final usedText = _formatBytes(capacity.used);
final totalText = _formatBytes(capacity.total);
final percent = capacity.usagePercentage;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('存储空间', style: theme.textTheme.bodySmall),
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (percent / 100).clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
],
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: children),
),
],
),
);
}
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () => _confirmLogout(context, auth),
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
}
String _securitySubtitle(UserSettingModel? settings) {
if (settings == null) return '';
final items = <String>[];
if (settings.twoFaEnabled) items.add('2FA已启用');
if (settings.passwordless) items.add('无密码登录');
return items.isEmpty ? '密码、2FA' : items.join('');
}
// ---- 存储包 ----
String _storagePackSummary(List<StoragePack> packs) {
final total = packs.fold<int>(0, (sum, p) => sum + p.size);
return '${_formatBytes(total)} · ${packs.length} 个存储包';
}
void _showStoragePacks(BuildContext context, List<StoragePack> packs) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.8,
expand: false,
builder: (ctx, controller) => ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
),
...packs.map((pack) => Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall),
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge),
],
),
const SizedBox(height: 4),
Text(
'激活: ${_formatDate(pack.activeSince)}'
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}',
style: Theme.of(ctx).textTheme.bodySmall,
),
if (pack.isExpired) ...[
const SizedBox(height: 4),
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)),
],
],
),
),
)),
const SizedBox(height: 16),
],
),
),
);
}
// ---- 取消会员 ----
Future<void> _cancelMembership(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('取消会员'),
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('确认取消'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await UserSettingService.instance.updateUserSetting(groupExpires: true);
if (!context.mounted) return;
await context.read<UserSettingProvider>().loadSettings();
if (mounted) ToastHelper.success('会员已取消');
} catch (e) {
if (mounted) ToastHelper.failure('取消会员失败: $e');
}
}
Future<void> _navigateTo(BuildContext context, Widget page) async {
await Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
if (!context.mounted) return;
if (mounted) {
context.read<UserSettingProvider>().loadAll();
}
}
Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: const Text('退出'),
),
],
),
);
if (confirmed == true && mounted) {
await auth.logout();
if (!context.mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}
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';
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
@@ -0,0 +1,730 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../data/models/share_model.dart';
import '../../../services/share_service.dart';
import '../../../core/utils/file_type_utils.dart';
import '../../widgets/toast_helper.dart';
class SharesPage extends StatefulWidget {
const SharesPage({super.key});
@override
State<SharesPage> createState() => _SharesPageState();
}
class _SharesPageState extends State<SharesPage> {
List<ShareModel> _shares = [];
bool _isLoading = false;
bool _hasMore = true;
String? _errorMessage;
String? _nextPageToken;
late ScrollController _scrollController;
static const _sharesPageListSize = 20;
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
_loadShares();
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMoreShares();
}
}
Future<bool> _loadShares({bool isLoadMore = false}) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await ShareService().listShares(
pageSize: _sharesPageListSize,
nextPageToken: isLoadMore ? _nextPageToken : null,
);
final List<dynamic> sharesData =
response['shares'] as List<dynamic>? ?? [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
final newShares = sharesData
.map((s) => ShareModel.fromJson(s as Map<String, dynamic>))
.toList();
setState(() {
_isLoading = false;
if (isLoadMore) {
_shares.addAll(newShares);
} else {
_shares = newShares;
}
_nextPageToken = pagination['next_token'] as String?;
_hasMore = _nextPageToken != null;
});
return true;
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
return false;
}
}
Future<void> _loadMoreShares() async {
if (!_hasMore || _isLoading) return;
await _loadShares(isLoadMore: true);
}
Future<void> _refreshShares() async {
final success = await _loadShares(isLoadMore: false);
if (mounted) {
if (success) {
ToastHelper.success('刷新成功');
} else {
ToastHelper.failure('刷新失败');
}
}
}
Future<void> _deleteShare(ShareModel share) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除分享'),
content: Text('确定删除分享 "${share.name}" 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) {
setState(() => _isLoading = true);
try {
await ShareService().deleteShare(id: share.id);
setState(() => _shares.remove(share));
if (mounted) ToastHelper.success('删除成功');
} catch (e) {
setState(() => _isLoading = false);
if (mounted) ToastHelper.failure('删除失败: $e');
}
}
}
Future<void> _editShare(ShareModel share) async {
final parts = share.url.split('/');
if (parts.length < 5) {
if (mounted) ToastHelper.error('分享链接格式错误');
return;
}
final shareId = parts[4];
final expireDaysController = TextEditingController(text: '7');
final downloadsController = TextEditingController();
final edited = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Row(
children: [
const Text('编辑分享'),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: share.url));
Navigator.of(dialogContext).pop();
ToastHelper.success('分享链接已复制');
},
tooltip: '复制分享链接',
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(
labelText: '文件名',
prefixIcon: const Icon(LucideIcons.fileText),
suffixIcon: IconButton(
icon: const Icon(LucideIcons.copy, size: 18),
onPressed: () {
Clipboard.setData(ClipboardData(text: share.name));
ToastHelper.success('文件名已复制');
},
tooltip: '复制文件名',
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
controller: TextEditingController(text: share.name),
readOnly: true,
),
const SizedBox(height: 16),
TextField(
controller: expireDaysController,
decoration: const InputDecoration(
labelText: '有效期(天)',
hintText: '留空则永久有效',
prefixIcon: Icon(LucideIcons.timer),
suffixText: '',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
TextField(
controller: downloadsController,
decoration: const InputDecoration(
labelText: '下载次数限制',
hintText: '留空则不限制',
prefixIcon: Icon(LucideIcons.download),
suffixText: '',
),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('保存'),
),
],
),
);
if (edited == true) {
final expireDaysText = expireDaysController.text.trim();
final expireDays =
expireDaysText.isEmpty ? null : int.tryParse(expireDaysText);
final downloadsText = downloadsController.text.trim();
final downloads =
downloadsText.isEmpty ? null : int.tryParse(downloadsText);
final expireSeconds = expireDays != null ? expireDays * 24 * 60 * 60 : null;
setState(() => _isLoading = true);
try {
final shareInfo = await ShareService().getShareInfo(
id: shareId,
password: share.password,
ownerExtended: true,
);
if (shareInfo.sourceUri == null) {
setState(() => _isLoading = false);
if (mounted) ToastHelper.error('无法获取文件信息');
return;
}
final uri = '${shareInfo.sourceUri}/${share.name}';
final newUrl = await ShareService().editShare(
id: shareId,
uri: uri,
isPrivate: share.isPrivate,
password: share.password,
shareView: share.shareView,
downloads: downloads,
expire: expireSeconds,
);
setState(() => _isLoading = false);
if (mounted) await _loadShares();
if (mounted) {
ToastHelper.success('修改成功');
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('分享链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(newUrl, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(LucideIcons.copy, size: 16),
label: const Text('复制到剪贴板'),
onPressed: () {
Clipboard.setData(ClipboardData(text: newUrl));
Navigator.of(dialogContext).pop();
ToastHelper.success('已复制到剪贴板');
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
} catch (e) {
setState(() => _isLoading = false);
if (mounted) ToastHelper.failure('修改失败: $e');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的分享'),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
onPressed: _refreshShares,
tooltip: '刷新',
),
],
),
body: Column(
children: [
_buildSearchBar(),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildSearchBar() {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: SizedBox(
height: 40,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索分享内容...',
prefixIcon: const Icon(LucideIcons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
isDense: true,
),
onChanged: (value) {
_searchQuery = value.toLowerCase();
setState(() {});
},
),
),
);
}
Widget _buildBody() {
final filteredShares = _searchQuery.isEmpty
? _shares
: _shares
.where((s) =>
s.name.toLowerCase().contains(_searchQuery) ||
s.url.toLowerCase().contains(_searchQuery))
.toList();
if (_isLoading && _shares.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: () => _loadShares(isLoadMore: false),
child: _errorMessage != null
? _buildErrorState()
: filteredShares.isEmpty
? (_searchQuery.isEmpty ? _buildEmptyState() : _buildNoSearchResult())
: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 800;
return isDesktop
? _buildDesktopLayout(filteredShares)
: _buildMobileLayout(filteredShares);
},
),
);
}
// ─── 桌面端布局 ───
Widget _buildDesktopLayout(List<ShareModel> shares) {
final colorScheme = Theme.of(context).colorScheme;
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: SizedBox(
width: double.infinity,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: DataTable(
headingRowColor:
WidgetStateProperty.all(colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('文件名', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('类型', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('浏览/下载', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('状态', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('创建时间', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('操作', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: shares.map((share) {
return DataRow(
cells: [
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Row(
children: [
Icon(_getShareIcon(share), size: 18, color: _getIconColor(share, colorScheme)),
const SizedBox(width: 12),
Expanded(child: Text(share.name, overflow: TextOverflow.ellipsis)),
],
),
),
),
DataCell(Text(share.isFolder ? '文件夹' : '文件')),
DataCell(Text('${share.visited} / ${share.downloaded ?? 0}')),
DataCell(_buildStatusBadge(share)),
DataCell(Text(_formatDate(share.createdAt), style: const TextStyle(fontSize: 12))),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: LucideIcons.pencil,
tooltip: '编辑',
onPressed: () => _editShare(share),
),
_buildActionButton(
icon: LucideIcons.copy,
tooltip: '复制链接',
onPressed: () {
Clipboard.setData(ClipboardData(text: share.url));
if (mounted) ToastHelper.success('链接已复制');
},
),
_buildActionButton(
icon: LucideIcons.trash2,
tooltip: '删除',
color: colorScheme.error,
onPressed: () => _deleteShare(share),
),
],
),
),
],
);
}).toList(),
),
),
),
);
}
// ─── 移动端布局 ───
Widget _buildMobileLayout(List<ShareModel> shares) {
final theme = Theme.of(context);
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: shares.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= shares.length) {
return const Center(child: CircularProgressIndicator());
}
final share = shares[index];
final iconColor = _getIconColor(share, theme.colorScheme);
return InkWell(
onTap: () => _editShare(share),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getShareIcon(share), size: 18, color: iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
share.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Row(
children: [
_buildStatusBadge(share),
const SizedBox(width: 8),
Text(
'浏览 ${share.visited} · 下载 ${share.downloaded ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.moreVertical, size: 18),
onPressed: () => _showMobileActionMenu(share),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
),
],
),
),
);
},
);
}
// ─── 状态徽章 ───
Widget _buildStatusBadge(ShareModel share) {
final colorScheme = Theme.of(context).colorScheme;
final isExpired = share.expired;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isExpired
? colorScheme.error.withValues(alpha: 0.1)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isExpired ? '已过期' : '正常',
style: TextStyle(
color: isExpired ? colorScheme.error : Colors.green,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
}
// ─── 桌面端操作按钮 ───
Widget _buildActionButton({
required IconData icon,
required String tooltip,
Color? color,
required VoidCallback onPressed,
}) {
return IconButton(
icon: Icon(icon, size: 18, color: color),
onPressed: onPressed,
tooltip: tooltip,
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
);
}
// ─── 图标与颜色 ───
IconData _getShareIcon(ShareModel share) {
if (share.isFolder) return LucideIcons.folder;
final name = share.name;
if (FileTypeUtils.isImage(name)) return LucideIcons.image;
if (FileTypeUtils.isPdf(name)) return LucideIcons.fileText;
if (FileTypeUtils.isVideo(name)) return LucideIcons.video;
if (FileTypeUtils.isAudio(name)) return LucideIcons.music;
if (FileTypeUtils.isMarkdown(name)) return LucideIcons.fileText;
if (FileTypeUtils.isTextCode(name)) return LucideIcons.code;
return LucideIcons.file;
}
Color _getIconColor(ShareModel share, ColorScheme colorScheme) {
if (share.isFolder) return Colors.amber.shade700;
final name = share.name;
if (FileTypeUtils.isImage(name)) return Colors.purple.shade600;
if (FileTypeUtils.isPdf(name)) return Colors.red.shade600;
if (FileTypeUtils.isVideo(name)) return Colors.orange.shade600;
if (FileTypeUtils.isAudio(name)) return Colors.blue.shade600;
if (FileTypeUtils.isMarkdown(name)) return Colors.teal.shade600;
if (FileTypeUtils.isTextCode(name)) return Colors.cyan.shade700;
return colorScheme.onSurfaceVariant;
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) return '今天';
if (diff.inDays == 1) return '昨天';
if (diff.inDays < 7) return '${diff.inDays} 天前';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// ─── 移动端菜单 ───
void _showMobileActionMenu(ShareModel share) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('修改分享设置'),
onTap: () {
Navigator.pop(context);
_editShare(share);
},
),
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('复制链接'),
onTap: () {
Navigator.pop(context);
Clipboard.setData(ClipboardData(text: share.url));
if (mounted) ToastHelper.success('链接已复制');
},
),
ListTile(
leading: Icon(LucideIcons.trash2, color: colorScheme.error),
title: Text('取消分享', style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_deleteShare(share);
},
),
],
),
),
);
}
// ─── 空状态 / 错误状态 ───
Widget _buildEmptyState() {
final theme = Theme.of(context);
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.share2, size: 48, color: theme.colorScheme.outline),
const SizedBox(height: 16),
Text('还没有分享过文件', style: TextStyle(color: theme.hintColor)),
],
),
),
),
],
);
}
Widget _buildNoSearchResult() {
final theme = Theme.of(context);
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.searchX, size: 48, color: theme.colorScheme.outline),
const SizedBox(height: 16),
Text('没有找到 "$_searchQuery"', style: TextStyle(color: theme.hintColor)),
],
),
),
),
],
);
}
Widget _buildErrorState() {
final theme = Theme.of(context);
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text('加载失败', style: TextStyle(color: theme.hintColor)),
const SizedBox(height: 8),
Text(_errorMessage ?? '未知错误',
style: TextStyle(fontSize: 12, color: theme.hintColor)),
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(LucideIcons.refreshCw, size: 18),
label: const Text('重试'),
onPressed: _refreshShares,
),
],
),
),
),
],
);
}
}
+309
View File
@@ -0,0 +1,309 @@
import 'package:animations/animations.dart';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../router/app_router.dart';
import '../files/files_page.dart';
import '../overview/overview_page.dart';
import '../tasks/tasks_page.dart';
import '../profile/profile_page.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> with GestureHandlerMixin {
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) {
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') {
await fileManager.goBack();
} else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) {
navProvider.setIndex(0);
} else {
await checkExitApp(context);
}
}
},
child: Consumer<NavigationProvider>(
builder: (context, navProvider, _) {
if (isDesktop) {
return _buildDesktopLayout(context, navProvider);
}
return _buildMobileLayout(context, navProvider);
},
),
);
}
Widget _buildPageContent(BuildContext context, int currentIndex) {
const pages = [
OverviewPage(),
FilesPage(),
TasksPage(),
ProfilePage(),
];
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation, secondaryAnimation) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: KeyedSubtree(
key: ValueKey(currentIndex),
child: pages[currentIndex],
),
);
}
Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) {
return Scaffold(
body: _buildPageContent(context, navProvider.currentIndex),
bottomNavigationBar: GlassmorphismContainer(
borderRadius: 0,
child: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
return NavigationBar(
height: 64,
selectedIndex: navProvider.currentIndex,
onDestinationSelected: (i) => navProvider.setIndex(i),
destinations: [
const NavigationDestination(
icon: Icon(LucideIcons.layoutDashboard),
selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700),
label: '概览',
),
const NavigationDestination(
icon: Icon(LucideIcons.folder),
selectedIcon: Icon(LucideIcons.folder, weight: 700),
label: '文件',
),
NavigationDestination(
icon: Badge(
isLabelVisible: activeCount > 0,
label: Text('$activeCount'),
child: const Icon(LucideIcons.listChecks),
),
selectedIcon: Badge(
isLabelVisible: activeCount > 0,
label: Text('$activeCount'),
child: const Icon(LucideIcons.listChecks, weight: 700),
),
label: '任务',
),
const NavigationDestination(
icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700),
label: '我的',
),
],
);
},
),
),
);
}
Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) {
final theme = Theme.of(context);
final authProvider = context.watch<AuthProvider>();
final user = authProvider.user;
final displayName = user?.nickname ?? '用户';
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: navProvider.currentIndex,
onDestinationSelected: (i) => navProvider.setIndex(i),
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: GestureDetector(
onTap: () => navProvider.setIndex(3),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: navProvider.currentIndex == 3
? Border.all(
color: theme.colorScheme.primary,
width: 2.5,
)
: null,
),
child: UserAvatar(
userId: user?.id ?? '',
email: user?.email,
displayName: displayName,
radius: 20,
),
),
),
),
destinations: [
const NavigationRailDestination(
icon: Icon(LucideIcons.layoutDashboard),
selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700),
label: Text('概览'),
),
const NavigationRailDestination(
icon: Icon(LucideIcons.folder),
selectedIcon: Icon(LucideIcons.folder, weight: 700),
label: Text('文件'),
),
NavigationRailDestination(
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
return Badge(
isLabelVisible: activeCount > 0,
label: Text('$activeCount'),
child: const Icon(LucideIcons.listChecks),
);
},
),
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
label: const Text('任务'),
),
const NavigationRailDestination(
icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700),
label: Text('我的'),
),
],
trailing: Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Divider(indent: 12, endIndent: 12),
_buildSecondaryNavItem(
context,
icon: LucideIcons.share2,
label: '我的分享',
onTap: () => Navigator.of(context).pushNamed(RouteNames.share),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.cloud,
label: 'WebDAV',
onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.download,
label: '离线下载',
onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.trash2,
label: '回收站',
onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin),
),
const Divider(indent: 12, endIndent: 12),
_buildSecondaryNavItem(
context,
icon: LucideIcons.settings,
label: '设置',
onTap: () => Navigator.of(context).pushNamed(RouteNames.settings),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.logOut,
label: '退出登录',
onTap: () => _handleLogout(context),
),
const SizedBox(height: 12),
],
),
),
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(
child: _buildPageContent(context, navProvider.currentIndex),
),
],
),
);
}
Widget _buildSecondaryNavItem(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: label,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Icon(icon, size: 22, color: theme.hintColor),
),
),
);
}
Future<void> _handleLogout(BuildContext context) async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出登录吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('退出'),
),
],
),
);
if (confirmed == true) {
await authProvider.logout();
fileManager.clearFiles();
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
}
}
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../router/app_router.dart';
import '../../../services/api_service.dart';
import '../../../services/server_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart';
/// 启动页
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
_initApp();
}
Future<void> _initApp() async {
// 先初始化服务器服务,获取正确的 baseUrl
await ServerService.instance.init();
// 根据服务器服务中的 baseUrl 设置 API 服务
final currentServer = ServerService.instance.currentServer;
if (currentServer != null) {
await _setApiBaseUrl(currentServer.baseUrl);
}
// 初始化 API 服务
await ApiService.instance.init();
// 使用 AuthProvider 检查登录状态
if (!mounted) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// 初始化 AuthProvider(会自动检查登录状态)
await authProvider.init();
if (!mounted) return;
if (authProvider.isAuthenticated) {
Navigator.of(context).pushReplacementNamed(RouteNames.home);
} else {
Navigator.of(context).pushReplacementNamed(RouteNames.login);
}
}
/// 设置 API baseUrl
Future<void> _setApiBaseUrl(String baseUrl) async {
final storageService = StorageService.instance;
await storageService.setCustomBaseUrl(baseUrl);
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 24),
Text(
'正在加载...',
style: TextStyle(
fontSize: 16,
color: Color(0xFF1E88E5),
),
),
],
),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:cloudreve4_flutter/data/models/upload_task_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'widgets/upload_tasks_tab.dart';
import 'widgets/download_tasks_tab.dart';
class TasksPage extends StatelessWidget {
const TasksPage({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('任务'),
bottom: const _TasksTabBar(),
),
body: const TabBarView(
children: [
UploadTasksTab(),
DownloadTasksTab(),
],
),
),
);
}
}
class _TasksTabBar extends StatelessWidget implements PreferredSizeWidget {
const _TasksTabBar();
@override
Size get preferredSize => const Size.fromHeight(48);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) {
final uploadActiveCount = uploadManager.allTasks
.where((t) =>
t.status == UploadStatus.uploading ||
t.status == UploadStatus.waiting ||
t.status == UploadStatus.paused)
.length;
final downloadActiveCount = downloadManager.activeTaskCount;
return TabBar(
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.upload, size: 16),
const SizedBox(width: 6),
const Text('上传'),
if (uploadActiveCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(minWidth: 18),
child: Text(
'$uploadActiveCount',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
],
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.download, size: 16),
const SizedBox(width: 6),
const Text('下载'),
if (downloadActiveCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(minWidth: 18),
child: Text(
'$downloadActiveCount',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
],
],
),
),
],
);
},
);
}
}
@@ -0,0 +1,579 @@
import 'dart:io';
import 'package:cloudreve4_flutter/data/models/download_task_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/download_progress_item.dart';
import 'package:cloudreve4_flutter/core/utils/date_utils.dart' as date_utils;
import 'package:cloudreve4_flutter/services/download_service.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
class DownloadTasksTab extends StatelessWidget {
const DownloadTasksTab({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, _) {
final allTasks = downloadManager.tasks;
final activeTasks = allTasks.where((t) =>
t.status == DownloadStatus.downloading || t.status == DownloadStatus.waiting || t.status == DownloadStatus.paused).toList();
final completedTasks = allTasks.where((t) => t.status == DownloadStatus.completed).toList();
final failedTasks = allTasks.where((t) =>
t.status == DownloadStatus.failed || t.status == DownloadStatus.cancelled).toList();
if (allTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.download, size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text('暂无下载任务', style: TextStyle(color: theme.hintColor)),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 800;
if (isDesktop) {
return _buildDesktopLayout(
context,
downloadManager,
allTasks: allTasks,
activeTasks: activeTasks,
failedTasks: failedTasks,
completedTasks: completedTasks,
);
}
return _buildMobileLayout(
context,
downloadManager,
activeTasks: activeTasks,
failedTasks: failedTasks,
completedTasks: completedTasks,
);
},
);
},
);
}
Widget _buildMobileLayout(
BuildContext context,
DownloadManagerProvider downloadManager, {
required List<DownloadTaskModel> activeTasks,
required List<DownloadTaskModel> failedTasks,
required List<DownloadTaskModel> completedTasks,
}) {
return ListView(
padding: const EdgeInsets.only(top: 8, bottom: 80),
children: [
if (activeTasks.isNotEmpty) ...[
_buildSectionHeader(context, '进行中', activeTasks.length),
...activeTasks.map((task) => DownloadProgressItem(
task: task,
onPause: () => downloadManager.pauseDownload(task.id),
onResume: () => downloadManager.resumeDownload(task.id),
onCancel: () => downloadManager.cancelDownload(task.id),
)),
],
if (failedTasks.isNotEmpty) ...[
_buildSectionHeader(context, '失败', failedTasks.length,
actionLabel: '清除失败',
onAction: () => _confirmClear(context, '失败', failedTasks.length, () => downloadManager.clearFailedTasks())),
...failedTasks.map((task) => DownloadProgressItem(
task: task,
onRetry: () => downloadManager.retryDownload(task.id),
onDelete: () => _confirmDeleteDownloadTask(context, task, downloadManager),
)),
],
if (completedTasks.isNotEmpty) ...[
_buildSectionHeader(context, '已完成', completedTasks.length,
actionLabel: '清除已完成',
onAction: () => _confirmClear(context, '已完成', completedTasks.length, () => downloadManager.clearCompletedTasks())),
...completedTasks.map((task) => DownloadProgressItem(
task: task,
onDelete: () => _confirmDeleteDownloadTask(context, task, downloadManager),
)),
],
],
);
}
Widget _buildDesktopLayout(
BuildContext context,
DownloadManagerProvider downloadManager, {
required List<DownloadTaskModel> allTasks,
required List<DownloadTaskModel> activeTasks,
required List<DownloadTaskModel> failedTasks,
required List<DownloadTaskModel> completedTasks,
}) {
final colorScheme = Theme.of(context).colorScheme;
final sortedTasks = [
...activeTasks.reversed,
...failedTasks.reversed,
...completedTasks.reversed,
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (failedTasks.isNotEmpty)
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: const Text('清除失败', style: TextStyle(fontSize: 12)),
onPressed: () => _confirmClear(context, '失败', failedTasks.length, () => downloadManager.clearFailedTasks()),
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
),
if (completedTasks.isNotEmpty && failedTasks.isEmpty)
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: const Text('清除已完成', style: TextStyle(fontSize: 12)),
onPressed: () => _confirmClear(context, '已完成', completedTasks.length, () => downloadManager.clearCompletedTasks()),
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
),
SizedBox(
width: double.infinity,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: DataTable(
headingRowColor: WidgetStateProperty.all(colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('名称')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('进度')),
DataColumn(label: Text('大小')),
DataColumn(label: Text('速度/完成时间')),
DataColumn(label: Text('操作')),
],
rows: sortedTasks.map((task) => _buildDownloadDataRow(context, task, downloadManager)).toList(),
),
),
),
],
),
);
}
DataRow _buildDownloadDataRow(
BuildContext context,
DownloadTaskModel task,
DownloadManagerProvider downloadManager,
) {
final colorScheme = Theme.of(context).colorScheme;
final errorColor = colorScheme.error;
final statusColor = _getStatusColor(task.status, waitingForWifi: task.waitingForWifi);
final statusIcon = _getStatusIcon(task.status, waitingForWifi: task.waitingForWifi);
final isActive = task.status == DownloadStatus.downloading ||
task.status == DownloadStatus.waiting ||
task.status == DownloadStatus.paused;
return DataRow(
cells: [
// 名称 (with status icon)
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(statusIcon, size: 18, color: statusColor),
),
const SizedBox(width: 10),
Flexible(
child: Text(
task.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// 状态
DataCell(
Text(
task.statusText,
style: TextStyle(color: statusColor, fontSize: 13),
),
),
// 进度
DataCell(
isActive
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
child: LinearProgressIndicator(
value: task.status == DownloadStatus.paused ? null : task.progress,
backgroundColor: colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
),
),
const SizedBox(width: 8),
Text(
task.status == DownloadStatus.paused ? '已暂停' : task.progressText,
style: const TextStyle(fontSize: 12),
),
],
)
: Text(
task.status == DownloadStatus.completed ? '100%' : '-',
style: const TextStyle(fontSize: 12),
),
),
// 大小
DataCell(
Text(
DownloadService.getReadableFileSize(task.fileSize),
style: const TextStyle(fontSize: 13),
),
),
// 速度/完成时间
DataCell(
Text(
task.status == DownloadStatus.completed
? (task.completedAt != null ? _formatDateTime(task.completedAt!) : '-')
: (task.speedText.isNotEmpty ? task.speedText : '-'),
style: TextStyle(
fontSize: 13,
color: task.status == DownloadStatus.completed
? null
: (task.speedText.isNotEmpty ? colorScheme.primary : null),
),
),
),
// 操作
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: _buildDesktopActionButtons(context, task, downloadManager, errorColor),
),
),
],
);
}
List<Widget> _buildDesktopActionButtons(
BuildContext context,
DownloadTaskModel task,
DownloadManagerProvider downloadManager,
Color errorColor,
) {
switch (task.status) {
case DownloadStatus.waiting:
if (task.waitingForWifi) {
return [
IconButton(
icon: Icon(Icons.cancel, size: 18, color: errorColor),
onPressed: () => downloadManager.cancelDownload(task.id),
tooltip: '取消',
),
];
}
return [];
case DownloadStatus.downloading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 18),
onPressed: () => downloadManager.pauseDownload(task.id),
tooltip: '暂停',
),
];
case DownloadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 18),
onPressed: () => downloadManager.resumeDownload(task.id),
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 18, color: errorColor),
onPressed: () => downloadManager.cancelDownload(task.id),
tooltip: '取消',
),
];
case DownloadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: () => downloadManager.retryDownload(task.id),
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 18, color: errorColor),
onPressed: () => _confirmDeleteDownloadTask(context, task, downloadManager),
tooltip: '删除',
),
];
case DownloadStatus.completed:
final showOpenFolder = !Platform.isAndroid;
return [
IconButton(
icon: const Icon(Icons.open_in_new, size: 18),
onPressed: () => _openDownloadedFile(context, task),
tooltip: '打开',
),
if (showOpenFolder)
IconButton(
icon: const Icon(Icons.folder_open, size: 18),
onPressed: () => _openFileFolder(context, task),
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: errorColor),
onPressed: () => _confirmDeleteDownloadTask(context, task, downloadManager),
tooltip: '删除',
),
];
case DownloadStatus.cancelled:
return [];
}
}
IconData _getStatusIcon(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? LucideIcons.wifi : LucideIcons.clock;
case DownloadStatus.downloading:
return LucideIcons.download;
case DownloadStatus.completed:
return LucideIcons.checkCircle2;
case DownloadStatus.paused:
return LucideIcons.pause;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? Colors.blue : Colors.grey;
case DownloadStatus.downloading:
return Colors.blue;
case DownloadStatus.completed:
return Colors.green;
case DownloadStatus.paused:
return Colors.orange;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return Colors.red;
}
}
Future<void> checkInstallPermission() async {
if (await Permission.requestInstallPackages.isDenied) {
await Permission.requestInstallPackages.request();
}
}
Future<void> _openDownloadedFile(
BuildContext context,
DownloadTaskModel task,
) async {
final file = File(task.savePath);
if (!await file.exists()) {
if (context.mounted) {
ToastHelper.error('文件不存在:${task.fileName}');
}
return;
}
try {
final ext = task.savePath.toString().split('.').last.toLowerCase();
if (ext == 'apk') {
await checkInstallPermission();
}
OpenResult openResult = await OpenFile.open(task.savePath);
AppLogger.d('下载对话框打开文件结果:${openResult.type}');
if (openResult.type == ResultType.done) {
AppLogger.d('成功打开文件:${task.fileName}');
} else {
if (context.mounted) {
ToastHelper.error(
'无法打开文件:${task.fileName} 错误信息: ${openResult.message}',
);
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件失败:$e');
}
}
}
Future<void> _openFileFolder(
BuildContext context,
DownloadTaskModel task,
) async {
try {
final dir = File(task.savePath).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done && context.mounted) {
ToastHelper.error('无法打开文件夹:${result.message}');
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件夹失败:$e');
}
}
}
Widget _buildSectionHeader(
BuildContext context,
String title,
int count, {
String? actionLabel,
VoidCallback? onAction,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
child: Row(
children: [
Text(
'$title ($count)',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (actionLabel != null && onAction != null)
TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: Text(actionLabel, style: const TextStyle(fontSize: 12)),
onPressed: onAction,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
);
}
Future<void> _confirmClear(BuildContext context, String label, int count, VoidCallback onConfirm) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('清除$label'),
content: Text('确定要清除 $count$label的任务吗'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('清除'),
),
],
),
);
if (confirmed == true) onConfirm();
}
Future<void> _confirmDeleteDownloadTask(
BuildContext context,
DownloadTaskModel task,
DownloadManagerProvider downloadManager,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除下载任务'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('确定要删除该任务吗?'),
const SizedBox(height: 8),
Text(task.fileName, style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Text('下载时间: ${_formatDateTime(task.createdAt)}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)),
Text('文件大小: ${date_utils.DateUtils.formatFileSize(task.fileSize)}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) downloadManager.deleteDownloadTask(task.id);
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
@@ -0,0 +1,521 @@
import 'package:cloudreve4_flutter/data/models/upload_task_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/upload_progress_item.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class UploadTasksTab extends StatelessWidget {
const UploadTasksTab({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer<UploadManagerProvider>(
builder: (context, uploadManager, _) {
final allTasks = uploadManager.allTasks;
final activeTasks = allTasks.where((t) =>
t.status == UploadStatus.uploading || t.status == UploadStatus.waiting || t.status == UploadStatus.paused).toList();
final completedTasks = allTasks.where((t) => t.status == UploadStatus.completed).toList();
final failedTasks = allTasks.where((t) =>
t.status == UploadStatus.failed || t.status == UploadStatus.cancelled).toList();
if (allTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.upload, size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text('暂无上传任务', style: TextStyle(color: theme.hintColor)),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 800;
if (isDesktop) {
return _buildDesktopLayout(
context,
uploadManager,
allTasks: allTasks,
activeTasks: activeTasks,
failedTasks: failedTasks,
completedTasks: completedTasks,
);
}
return _buildMobileLayout(
context,
uploadManager,
activeTasks: activeTasks,
failedTasks: failedTasks,
completedTasks: completedTasks,
);
},
);
},
);
}
Widget _buildMobileLayout(
BuildContext context,
UploadManagerProvider uploadManager, {
required List<UploadTaskModel> activeTasks,
required List<UploadTaskModel> failedTasks,
required List<UploadTaskModel> completedTasks,
}) {
return ListView(
padding: const EdgeInsets.only(top: 8, bottom: 80),
children: [
if (activeTasks.isNotEmpty) ...[
_buildSectionHeader(context, '进行中', activeTasks.length),
...activeTasks.map((task) => UploadProgressItem(
task: task,
onPause: () => uploadManager.cancelUpload(task.id),
onResume: () => uploadManager.retryUpload(task.id),
onCancel: () => uploadManager.cancelUpload(task.id),
)),
],
if (failedTasks.isNotEmpty) ...[
_buildSectionHeader(context, '失败', failedTasks.length,
actionLabel: '清除失败',
onAction: () => _confirmClear(context, '失败', failedTasks.length, () => uploadManager.clearFailedTasks())),
...failedTasks.map((task) => UploadProgressItem(
task: task,
onRetry: () => uploadManager.retryUpload(task.id),
onDelete: () => _confirmDeleteUploadTask(context, task, uploadManager),
)),
],
if (completedTasks.isNotEmpty) ...[
_buildSectionHeader(context, '已完成', completedTasks.length,
actionLabel: '清除已完成',
onAction: () => _confirmClear(context, '已完成', completedTasks.length, () => uploadManager.clearCompletedTasks())),
...completedTasks.map((task) => UploadProgressItem(
task: task,
onNavigate: () => _navigateToUploadedFile(context, task),
onDelete: () => _confirmDeleteUploadTask(context, task, uploadManager),
)),
],
],
);
}
Widget _buildDesktopLayout(
BuildContext context,
UploadManagerProvider uploadManager, {
required List<UploadTaskModel> allTasks,
required List<UploadTaskModel> activeTasks,
required List<UploadTaskModel> failedTasks,
required List<UploadTaskModel> completedTasks,
}) {
final colorScheme = Theme.of(context).colorScheme;
final sortedTasks = [
...activeTasks.reversed,
...failedTasks.reversed,
...completedTasks.reversed,
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (failedTasks.isNotEmpty)
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: const Text('清除失败', style: TextStyle(fontSize: 12)),
onPressed: () => _confirmClear(context, '失败', failedTasks.length, () => uploadManager.clearFailedTasks()),
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
),
if (completedTasks.isNotEmpty && failedTasks.isEmpty)
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: const Text('清除已完成', style: TextStyle(fontSize: 12)),
onPressed: () => _confirmClear(context, '已完成', completedTasks.length, () => uploadManager.clearCompletedTasks()),
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
),
SizedBox(
width: double.infinity,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: DataTable(
headingRowColor: WidgetStateProperty.all(colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('名称')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('进度')),
DataColumn(label: Text('大小')),
DataColumn(label: Text('速度/完成时间')),
DataColumn(label: Text('操作')),
],
rows: sortedTasks.map((task) => _buildUploadDataRow(context, task, uploadManager)).toList(),
),
),
),
],
),
);
}
DataRow _buildUploadDataRow(
BuildContext context,
UploadTaskModel task,
UploadManagerProvider uploadManager,
) {
final colorScheme = Theme.of(context).colorScheme;
final errorColor = colorScheme.error;
final statusColor = _getStatusColor(task.status);
final statusIcon = _getStatusIcon(task.status);
final isActive = task.status == UploadStatus.uploading ||
task.status == UploadStatus.waiting ||
task.status == UploadStatus.paused;
return DataRow(
cells: [
// 名称 (with status icon)
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(statusIcon, size: 18, color: statusColor),
),
const SizedBox(width: 10),
Flexible(
child: Text(
task.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// 状态
DataCell(
Text(
task.statusText,
style: TextStyle(color: statusColor, fontSize: 13),
),
),
// 进度
DataCell(
isActive
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
child: LinearProgressIndicator(
value: task.status == UploadStatus.paused ? null : task.progress,
backgroundColor: colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
),
),
const SizedBox(width: 8),
Text(
task.status == UploadStatus.paused ? '已暂停' : task.progressText,
style: const TextStyle(fontSize: 12),
),
],
)
: Text(
task.status == UploadStatus.completed ? '100%' : '-',
style: const TextStyle(fontSize: 12),
),
),
// 大小
DataCell(Text(task.readableFileSize, style: const TextStyle(fontSize: 13))),
// 速度/完成时间
DataCell(
Text(
task.status == UploadStatus.completed
? (task.completedAt != null ? _formatDateTime(task.completedAt!) : '-')
: (task.speedText.isNotEmpty ? task.speedText : '-'),
style: TextStyle(
fontSize: 13,
color: task.status == UploadStatus.completed
? null
: (task.speedText.isNotEmpty ? colorScheme.primary : null),
),
),
),
// 操作
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: _buildDesktopActionButtons(context, task, uploadManager, errorColor),
),
),
],
);
}
List<Widget> _buildDesktopActionButtons(
BuildContext context,
UploadTaskModel task,
UploadManagerProvider uploadManager,
Color errorColor,
) {
switch (task.status) {
case UploadStatus.waiting:
case UploadStatus.uploading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 18),
onPressed: () => uploadManager.cancelUpload(task.id),
tooltip: '暂停',
),
];
case UploadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 18),
onPressed: () => uploadManager.retryUpload(task.id),
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 18, color: errorColor),
onPressed: () => uploadManager.cancelUpload(task.id),
tooltip: '取消',
),
];
case UploadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: () => uploadManager.retryUpload(task.id),
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 18, color: errorColor),
onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager),
tooltip: '删除',
),
];
case UploadStatus.completed:
return [
IconButton(
icon: const Icon(LucideIcons.folderOpen, size: 18),
onPressed: () => _navigateToUploadedFile(context, task),
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: errorColor),
onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager),
tooltip: '删除',
),
];
case UploadStatus.cancelled:
return [
IconButton(
icon: Icon(Icons.delete, size: 18, color: errorColor),
onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager),
tooltip: '删除',
),
];
}
}
IconData _getStatusIcon(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return LucideIcons.clock;
case UploadStatus.uploading:
return LucideIcons.upload;
case UploadStatus.completed:
return LucideIcons.checkCircle2;
case UploadStatus.paused:
return LucideIcons.pause;
case UploadStatus.failed:
case UploadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return Colors.orange;
case UploadStatus.uploading:
return Colors.blue;
case UploadStatus.completed:
return Colors.green;
case UploadStatus.paused:
return Colors.orange;
case UploadStatus.failed:
case UploadStatus.cancelled:
return Colors.red;
}
}
Widget _buildSectionHeader(
BuildContext context,
String title,
int count, {
String? actionLabel,
VoidCallback? onAction,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
child: Row(
children: [
Text(
'$title ($count)',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (actionLabel != null && onAction != null)
TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14),
label: Text(actionLabel, style: const TextStyle(fontSize: 12)),
onPressed: onAction,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
);
}
Future<void> _confirmClear(BuildContext context, String label, int count, VoidCallback onConfirm) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('清除$label'),
content: Text('确定要清除 $count$label的任务吗'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('清除'),
),
],
),
);
if (confirmed == true) onConfirm();
}
Future<void> _confirmDeleteUploadTask(
BuildContext context,
UploadTaskModel task,
UploadManagerProvider uploadManager,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除上传任务'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('确定要删除该任务吗?'),
const SizedBox(height: 8),
Text(task.fileName, style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Text('上传时间: ${_formatDateTime(task.createdAt)}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)),
Text('文件大小: ${task.readableFileSize}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) uploadManager.removeTask(task.id);
}
void _navigateToUploadedFile(BuildContext context, UploadTaskModel task) {
// targetPath 格式: cloudreve://my/folder
final targetPath = task.targetPath;
String relativePath;
if (targetPath.startsWith('cloudreve://my')) {
relativePath = targetPath.replaceFirst('cloudreve://my', '');
if (relativePath.isEmpty) relativePath = '/';
} else {
relativePath = targetPath;
}
// 构造文件完整路径用于高亮
final filePath = targetPath.endsWith('/')
? '$targetPath${task.fileName}'
: '$targetPath/${task.fileName}';
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
fileManager.navigateAndHighlight(relativePath, filePath);
navProvider.setIndex(1);
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
@@ -0,0 +1,721 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../data/models/dav_account_model.dart';
import '../../../data/models/server_model.dart';
import '../../../services/webdav_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/toast_helper.dart';
class WebdavPage extends StatefulWidget {
const WebdavPage({super.key});
@override
State<WebdavPage> createState() => _WebdavPageState();
}
class _WebdavPageState extends State<WebdavPage> {
List<DavAccountModel> _accounts = [];
bool _isLoading = false;
String? _errorMessage;
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadAccounts();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('WebDAV'),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
onPressed: () => _loadAccounts(),
tooltip: '刷新',
),
],
),
body: Column(
children: [
_buildSearchBar(),
Expanded(child: _buildBody()),
],
),
floatingActionButton: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 800;
if (isDesktop) return const SizedBox.shrink();
return FloatingActionButton.extended(
onPressed: () => _showCreateDialog(context),
label: const Text('添加账户'),
icon: const Icon(LucideIcons.plus),
);
},
),
);
}
Widget _buildSearchBar() {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: SizedBox(
height: 40,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索 WebDAV 账户...',
prefixIcon: const Icon(LucideIcons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
isDense: true,
),
onChanged: (value) {
_searchQuery = value.toLowerCase();
setState(() {});
},
),
),
);
}
Widget _buildBody() {
final filteredAccounts = _searchQuery.isEmpty
? _accounts
: _accounts
.where((a) =>
a.name.toLowerCase().contains(_searchQuery) ||
a.uri.toLowerCase().contains(_searchQuery))
.toList();
if (_isLoading && _accounts.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) return _buildErrorState();
if (filteredAccounts.isEmpty) {
return _searchQuery.isEmpty ? _buildEmptyState() : _buildNoSearchResult();
}
return RefreshIndicator(
onRefresh: () => _loadAccounts(),
child: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 800;
return isDesktop
? _buildDesktopLayout(filteredAccounts)
: _buildMobileLayout(filteredAccounts);
},
),
);
}
// ─── 桌面端布局 ───
Widget _buildDesktopLayout(List<DavAccountModel> accounts) {
final colorScheme = Theme.of(context).colorScheme;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: DataTable(
headingRowColor:
WidgetStateProperty.all(colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('名称', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('URI', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('密码', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('创建时间', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('操作', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: accounts.map((account) {
return DataRow(
cells: [
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Row(
children: [
_buildAccountIcon(colorScheme, size: 18),
const SizedBox(width: 12),
Expanded(child: Text(account.name, overflow: TextOverflow.ellipsis)),
],
),
),
),
DataCell(Text(account.uri, style: const TextStyle(fontSize: 12))),
DataCell(Text(_maskPassword(account.password),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
DataCell(Text(_formatDate(account.createdAt), style: const TextStyle(fontSize: 12))),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: LucideIcons.copy,
tooltip: '复制凭据',
onPressed: () => _copyCredentials(context, account),
),
_buildActionButton(
icon: LucideIcons.pencil,
tooltip: '编辑',
onPressed: () => _showEditDialog(context, account),
),
_buildActionButton(
icon: LucideIcons.trash2,
tooltip: '删除',
color: colorScheme.error,
onPressed: () => _showDeleteDialog(context, account),
),
],
),
),
],
);
}).toList(),
),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _showCreateDialog(context),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('添加账户'),
),
],
),
);
}
// ─── 移动端布局 ───
Widget _buildMobileLayout(List<DavAccountModel> accounts) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: accounts.length,
itemBuilder: (context, index) {
final account = accounts[index];
return InkWell(
onTap: () => _showMobileActionMenu(account),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
_buildAccountIcon(colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'${account.uri} · ${_maskPassword(account.password)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.moreVertical, size: 18),
onPressed: () => _showMobileActionMenu(account),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
),
],
),
),
);
},
);
}
// ─── 账户图标 ───
Widget _buildAccountIcon(ColorScheme colorScheme, {double size = 18}) {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(LucideIcons.cloud, color: colorScheme.onPrimaryContainer, size: size),
);
}
// ─── 桌面端操作按钮 ───
Widget _buildActionButton({
required IconData icon,
required String tooltip,
Color? color,
required VoidCallback onPressed,
}) {
return IconButton(
icon: Icon(icon, size: 18, color: color),
onPressed: onPressed,
tooltip: tooltip,
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
);
}
String _maskPassword(String password) {
if (password.length <= 4) return '••••';
return '••••${password.substring(password.length - 4)}';
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) return '今天';
if (diff.inDays == 1) return '昨天';
if (diff.inDays < 7) return '${diff.inDays} 天前';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// ─── 移动端菜单 ───
void _showMobileActionMenu(DavAccountModel account) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('复制凭据'),
onTap: () {
Navigator.pop(context);
_copyCredentials(this.context, account);
},
),
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('编辑账户'),
onTap: () {
Navigator.pop(context);
_showEditDialog(this.context, account);
},
),
ListTile(
leading: Icon(LucideIcons.trash2, color: colorScheme.error),
title: Text('删除账户', style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_showDeleteDialog(this.context, account);
},
),
],
),
),
);
}
// ─── 空状态 / 错误状态 ───
Widget _buildEmptyState() {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.cloud, size: 48, color: theme.colorScheme.outline),
const SizedBox(height: 16),
Text('暂无 WebDAV 账户', style: TextStyle(color: theme.hintColor)),
const SizedBox(height: 8),
Text('点击下方按钮添加账户',
style: TextStyle(fontSize: 12, color: theme.hintColor)),
],
),
);
}
Widget _buildNoSearchResult() {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.searchX, size: 48, color: theme.colorScheme.outline),
const SizedBox(height: 16),
Text('没有找到 "$_searchQuery"', style: TextStyle(color: theme.hintColor)),
],
),
);
}
Widget _buildErrorState() {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text('加载失败', style: TextStyle(color: theme.hintColor)),
const SizedBox(height: 8),
Text(_errorMessage ?? '未知错误',
style: TextStyle(fontSize: 12, color: theme.hintColor)),
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(LucideIcons.refreshCw, size: 18),
label: const Text('重试'),
onPressed: _loadAccounts,
),
],
),
);
}
// ─── 数据操作 ───
Future<void> _loadAccounts() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await WebdavService().listAccounts(pageSize: 50);
final accountsData = response as Map<String, dynamic>?;
final accountsList = accountsData?['accounts'] as List<dynamic>? ?? [];
final accounts = accountsList
.map((a) => DavAccountModel.fromJson(a as Map<String, dynamic>))
.toList();
if (!mounted) return;
setState(() {
_accounts = accounts;
_isLoading = false;
});
ToastHelper.success('刷新成功');
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
ToastHelper.failure('刷新失败: $e');
}
}
void _copyCredentials(BuildContext context, DavAccountModel account) async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final username = authProvider.user?.email ?? account.id;
final storageService = StorageService.instance;
final servers = await storageService.servers;
final lastLabel = await storageService.lastSelectedServerLabel;
String davUrl = account.uri;
if (lastLabel != null) {
final currentServer = servers.firstWhere(
(s) => s.label == lastLabel,
orElse: () => ServerModel(label: '', baseUrl: ''),
);
if (currentServer.baseUrl.isNotEmpty) {
final cleanBaseUrl = currentServer.baseUrl.replaceAll(RegExp(r'/api/v4/?$'), '');
davUrl = '$cleanBaseUrl/dav';
}
}
final credentials = '地址: $davUrl\n用户: $username\n密码: ${account.password}';
Clipboard.setData(ClipboardData(text: credentials));
if (mounted) ToastHelper.success('凭据已复制到剪贴板');
}
Future<void> _showCreateDialog(BuildContext context) async {
final nameController = TextEditingController();
final uriController = TextEditingController();
final proxyController = TextEditingController(text: 'false');
final readonlyController = TextEditingController(text: 'false');
final created = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('添加 WebDAV 账户'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: '名称',
hintText: '请输入备注名称',
prefixIcon: Icon(LucideIcons.tag),
),
),
const SizedBox(height: 16),
TextField(
controller: uriController,
decoration: const InputDecoration(
labelText: 'URI',
hintText: '/ or /folder',
prefixIcon: Icon(LucideIcons.link),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: proxyController,
decoration: const InputDecoration(
labelText: '反向代理',
hintText: 'true/false',
prefixIcon: Icon(LucideIcons.arrowLeftRight),
),
keyboardType: TextInputType.text,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: readonlyController,
decoration: const InputDecoration(
labelText: '只读',
hintText: 'true/false',
prefixIcon: Icon(LucideIcons.eyeOff),
),
keyboardType: TextInputType.text,
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('创建'),
),
],
),
);
if (!mounted) return;
if (created == true) {
final name = nameController.text.trim();
String uri = uriController.text.trim();
final proxy = proxyController.text.trim().toLowerCase() == 'true';
final readonly = readonlyController.text.trim().toLowerCase() == 'true';
if (name.isEmpty || uri.isEmpty) {
ToastHelper.error('请填写名称和URI');
return;
}
setState(() => _isLoading = true);
try {
final account = await WebdavService().createAccount(
uri: 'cloudreve://my$uri',
name: name,
proxy: proxy,
readonly: readonly,
);
if (!mounted) return;
setState(() {
_accounts.add(account);
_isLoading = false;
});
ToastHelper.success('创建成功');
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ToastHelper.failure('创建失败: $e');
}
}
}
Future<void> _showEditDialog(BuildContext context, DavAccountModel account) async {
final nameController = TextEditingController(text: account.name);
final uriController = TextEditingController(text: account.uri);
final updated = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('编辑 WebDAV 账户'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: '名称',
hintText: '请输入备注名称',
prefixIcon: Icon(LucideIcons.tag),
),
),
const SizedBox(height: 16),
TextField(
controller: uriController,
decoration: const InputDecoration(
labelText: 'URI',
hintText: 'cloudreve://my',
prefixIcon: Icon(LucideIcons.link),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('保存'),
),
],
),
);
if (!mounted) return;
if (updated == true) {
final name = nameController.text.trim();
final uri = uriController.text.trim();
if (name.isEmpty || uri.isEmpty) {
ToastHelper.error('请填写名称和URI');
return;
}
setState(() => _isLoading = true);
try {
await WebdavService().updateAccount(id: account.id, name: name, uri: uri);
if (!mounted) return;
setState(() {
final index = _accounts.indexWhere((a) => a.id == account.id);
if (index != -1) {
_accounts[index] = account.copyWith(name: name, uri: uri);
}
_isLoading = false;
});
ToastHelper.success('更新成功');
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ToastHelper.failure('更新失败: $e');
}
}
}
Future<void> _showDeleteDialog(BuildContext context, DavAccountModel account) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除账户'),
content: Text('确定要删除 WebDAV 账户 "${account.name}" 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (!mounted) return;
if (confirmed == true) {
setState(() => _isLoading = true);
try {
await WebdavService().deleteAccount(account.id);
if (!mounted) return;
setState(() {
_accounts.removeWhere((a) => a.id == account.id);
_isLoading = false;
});
ToastHelper.success('删除成功');
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ToastHelper.failure('删除失败: $e');
}
}
}
}
extension DavAccountModelExtension on DavAccountModel {
DavAccountModel copyWith({
String? name,
String? uri,
DateTime? createdAt,
String? password,
String? options,
}) {
return DavAccountModel(
id: id,
createdAt: createdAt ?? this.createdAt,
name: name ?? this.name,
uri: uri ?? this.uri,
password: password ?? this.password,
options: options ?? this.options,
);
}
}
@@ -0,0 +1,220 @@
import 'package:flutter/foundation.dart';
import '../../data/models/admin_model.dart';
import '../../services/admin_service.dart';
import '../../core/utils/app_logger.dart';
enum AdminState { idle, loading, error }
/// 管理员数据 Provider
class AdminProvider extends ChangeNotifier {
AdminState _state = AdminState.idle;
List<AdminGroupModel> _groups = [];
List<AdminUserModel> _users = [];
PaginationModel? _groupsPagination;
PaginationModel? _usersPagination;
String? _errorMessage;
// 用户多选状态
final Set<int> _selectedUserIds = {};
bool _isSelectingUsers = false;
AdminState get state => _state;
List<AdminGroupModel> get groups => _groups;
List<AdminUserModel> get users => _users;
PaginationModel? get groupsPagination => _groupsPagination;
PaginationModel? get usersPagination => _usersPagination;
String? get errorMessage => _errorMessage;
bool get isLoading => _state == AdminState.loading;
Set<int> get selectedUserIds => _selectedUserIds;
bool get isSelectingUsers => _isSelectingUsers;
bool get hasSelectedUsers => _selectedUserIds.isNotEmpty;
final AdminService _service = AdminService.instance;
/// 加载用户组列表
Future<void> loadGroups({int page = 1}) async {
try {
final response = await _service.getGroups(page: page);
_groups = response.groups;
_groupsPagination = response.pagination;
notifyListeners();
} catch (e) {
AppLogger.d('加载用户组失败: $e');
_errorMessage = e.toString();
notifyListeners();
}
}
/// 加载用户列表
Future<void> loadUsers({int page = 1}) async {
try {
final response = await _service.getUsers(page: page);
_users = response.users;
_usersPagination = response.pagination;
notifyListeners();
} catch (e) {
AppLogger.d('加载用户列表失败: $e');
_errorMessage = e.toString();
notifyListeners();
}
}
/// 加载全部管理员数据
Future<void> loadAll() async {
_setState(AdminState.loading);
try {
await Future.wait([
loadGroups(),
loadUsers(),
]);
_setState(AdminState.idle);
} catch (e) {
_errorMessage = e.toString();
_setState(AdminState.error);
}
}
/// 创建用户组
Future<bool> createGroup(String name) async {
try {
final group = await _service.createGroup(name);
_groups.insert(0, group);
if (_groupsPagination != null) {
_groupsPagination = PaginationModel(
page: _groupsPagination!.page,
pageSize: _groupsPagination!.pageSize,
totalItems: _groupsPagination!.totalItems + 1,
);
}
notifyListeners();
return true;
} catch (e) {
AppLogger.d('创建用户组失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 删除用户组(会先检查组下是否有用户)
Future<String?> deleteGroup(int groupId) async {
try {
final detail = await _service.getGroupDetail(groupId);
if ((detail.totalUsers ?? 0) > 0) {
return '该组下有 ${detail.totalUsers} 个用户,请先删除或迁移用户';
}
await _service.deleteGroup(groupId);
_groups.removeWhere((g) => g.id == groupId);
if (_groupsPagination != null) {
_groupsPagination = PaginationModel(
page: _groupsPagination!.page,
pageSize: _groupsPagination!.pageSize,
totalItems: _groupsPagination!.totalItems - 1,
);
}
notifyListeners();
return null;
} catch (e) {
AppLogger.d('删除用户组失败: $e');
return e.toString();
}
}
/// 创建用户
Future<bool> createUser({
required String email,
required String nick,
required String password,
required int groupId,
}) async {
try {
final user = await _service.createUser(
email: email,
nick: nick,
password: password,
groupId: groupId,
);
_users.insert(0, user);
if (_usersPagination != null) {
_usersPagination = PaginationModel(
page: _usersPagination!.page,
pageSize: _usersPagination!.pageSize,
totalItems: _usersPagination!.totalItems + 1,
);
}
notifyListeners();
return true;
} catch (e) {
AppLogger.d('创建用户失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 批量删除用户
Future<bool> batchDeleteUsers(List<int> ids) async {
try {
await _service.batchDeleteUsers(ids);
_users.removeWhere((u) => ids.contains(u.id));
if (_usersPagination != null) {
_usersPagination = PaginationModel(
page: _usersPagination!.page,
pageSize: _usersPagination!.pageSize,
totalItems: _usersPagination!.totalItems - ids.length,
);
}
exitSelectMode();
notifyListeners();
return true;
} catch (e) {
AppLogger.d('批量删除用户失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
// --- 多选模式 ---
void toggleSelectMode() {
_isSelectingUsers = !_isSelectingUsers;
if (!_isSelectingUsers) {
_selectedUserIds.clear();
}
notifyListeners();
}
void exitSelectMode() {
_isSelectingUsers = false;
_selectedUserIds.clear();
notifyListeners();
}
void toggleUserSelection(int userId) {
if (_selectedUserIds.contains(userId)) {
_selectedUserIds.remove(userId);
} else {
_selectedUserIds.add(userId);
}
notifyListeners();
}
void selectAllUsers() {
_selectedUserIds.clear();
_selectedUserIds.addAll(_users.map((u) => u.id));
notifyListeners();
}
void clearUserSelection() {
_selectedUserIds.clear();
notifyListeners();
}
bool isUserSelected(int userId) => _selectedUserIds.contains(userId);
void _setState(AdminState state) {
_state = state;
notifyListeners();
}
}
@@ -0,0 +1,333 @@
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/foundation.dart';
import '../../data/models/server_model.dart';
import '../../data/models/user_model.dart';
import '../../services/auth_service.dart';
import '../../services/server_service.dart';
import '../../services/storage_service.dart';
import '../../services/api_service.dart';
import '../../core/exceptions/app_exception.dart';
import '../../core/utils/app_logger.dart';
/// 认证状态
enum AuthState { loading, authenticated, unauthenticated, error }
/// 认证Provider
class AuthProvider extends ChangeNotifier {
AuthState _state = AuthState.unauthenticated;
UserModel? _user;
String? _errorMessage;
bool _hasRefreshTokenExpired = false;
AuthState get state => _state;
UserModel? get user => _user;
String? get errorMessage => _errorMessage;
bool get isAuthenticated => _state == AuthState.authenticated;
bool get isLoading => _state == AuthState.loading;
bool get hasRefreshTokenExpired => _hasRefreshTokenExpired;
bool get isAdmin {
final name = _user?.group?.name.toLowerCase();
return name == 'admin' || name == '管理员';
}
/// 当前选中的服务器
ServerModel? get currentServer => ServerService.instance.currentServer;
/// 获取当前用户的 token
TokenModel? get token => _user?.token;
/// 初始化
Future<void> init() async {
try {
setState(AuthState.loading);
// 初始化服务器服务
await ServerService.instance.init();
// 获取当前服务器
final server = ServerService.instance.currentServer;
if (server == null) {
setState(AuthState.unauthenticated);
return;
}
// 设置 API 的 baseUrl
await _setApiBaseUrl(server.baseUrl);
// 设置 ApiService 的认证回调
_setupApiCallbacks();
// 检查是否有保存的登录信息
if (server.user != null && server.user!.token != null) {
// 有保存的用户信息,检查 token 是否过期
if (!server.user!.token!.isRefreshTokenExpired) {
// Refresh token 未过期,设置用户信息
setUser(server.user);
setState(AuthState.authenticated);
return;
} else {
// Refresh token 已过期,清除登录信息
await ServerService.instance.clearCurrentServerLogin();
}
}
_user = null;
setState(AuthState.unauthenticated);
} catch (e) {
AppLogger.d('AuthProvider 初始化失败: $e');
_user = null;
setState(AuthState.unauthenticated);
}
}
/// 设置 ApiService 的认证回调
void _setupApiCallbacks() {
ApiService.setAuthCallbacks(
getToken: () async {
// 返回当前的 access token
return _user?.token?.accessToken;
},
refreshToken: () async {
// 刷新 token
try {
await refreshToken();
} catch (e) {
// 刷新失败,设置过期标志
if (e is RefreshTokenExpiredException) {
setRefreshTokenExpired();
}
rethrow;
}
},
clearAuth: () async {
// 清除认证数据
await ServerService.instance.clearCurrentServerLogin();
setRefreshTokenExpired();
},
);
}
/// 设置 API baseUrl
Future<void> _setApiBaseUrl(String baseUrl) async {
// 同时更新存储和 ApiService 的 baseUrl
final storageService = StorageService.instance;
await storageService.setCustomBaseUrl(baseUrl);
await ApiService.instance.setBaseUrl(baseUrl);
}
/// 密码登录
Future<bool> passwordLogin({
required String email,
required String password,
bool rememberMe = false,
}) async {
try {
setState(AuthState.loading);
// 获取当前服务器
final server = ServerService.instance.currentServer;
if (server == null) {
_errorMessage = '服务器初始化失败';
_user = null;
setState(AuthState.error);
return false;
}
// 每次登录时都重新设置 API 的 baseUrl,确保使用最新的服务器地址
await _setApiBaseUrl(server.baseUrl);
// 执行登录
final response = await AuthService.instance.passwordLogin(
email: email,
password: password,
);
AppLogger.d('AuthProvider 登录成功: $response');
// 保存登录信息到当前服务器(包含完整 user 和 token
await ServerService.instance.updateCurrentServerLogin(
email: rememberMe ? email : null,
password: rememberMe ? password : null,
user: response.user,
rememberMe: rememberMe,
);
setUser(response.user);
setState(AuthState.authenticated);
return true;
} on TwoFactorRequiredException {
// 两步验证需要,不设置 error 状态,重新抛出让调用方处理
setState(AuthState.unauthenticated);
rethrow;
} catch (e) {
_errorMessage = e.toString();
_user = null;
setState(AuthState.error);
return false;
}
}
/// 两步验证登录
Future<bool> twoFactorLogin({
required String otp,
required String sessionId,
required String email,
required String password,
bool rememberMe = false,
}) async {
try {
setState(AuthState.loading);
final response = await AuthService.instance.twoFactorLogin(
otp: otp,
sessionId: sessionId,
);
await ServerService.instance.updateCurrentServerLogin(
email: rememberMe ? email : null,
password: rememberMe ? password : null,
user: response.user,
rememberMe: rememberMe,
);
setUser(response.user);
setState(AuthState.authenticated);
return true;
} catch (e) {
_errorMessage = e.toString();
_user = null;
setState(AuthState.error);
return false;
}
}
/// 登出
Future<void> logout() async {
try {
// 调用登出 API(需要 token
if (token?.refreshToken != null) {
try {
await AuthService.instance.logout();
} catch (e) {
// 登出 API 调用失败不影响本地清理
AppLogger.d('登出 API 调用失败: $e');
}
}
// 清除当前服务器的登录信息
await ServerService.instance.clearCurrentServerLogin();
_clearUserData();
// 清除头像缓存
await UserAvatar.clearAllCache();
setState(AuthState.unauthenticated);
} catch (e) {
// 即使出错也要清除本地状态
_clearUserData();
setState(AuthState.unauthenticated);
_errorMessage = e.toString();
}
}
/// 刷新用户信息
Future<void> refreshUser() async {
try {
final user = await AuthService.instance.getCurrentUser();
setUser(user);
// 更新服务器中的用户信息(保留 token)
final server = ServerService.instance.currentServer;
if (server != null && token != null) {
await ServerService.instance.updateCurrentServerLogin(
user: user.copyWith(token: token),
);
}
} catch (e) {
_errorMessage = e.toString();
}
}
/// 刷新 token
Future<void> refreshToken() async {
try {
final currentToken = token;
if (currentToken == null || currentToken.isRefreshTokenExpired) {
ToastHelper.failure('已注销或Refresh token 已过期,需要重新登录');
throw Exception('Refresh token 已过期,需要重新登录');
}
// 调用刷新 token 接口
final newToken = await AuthService.instance.refreshToken(currentToken.refreshToken);
// 更新用户信息中的 token
final updatedUser = _user!.copyWith(token: newToken);
// 保存到服务器
await ServerService.instance.updateCurrentServerLogin(user: updatedUser);
setUser(updatedUser);
} catch (e) {
ToastHelper.error('刷新 token 失败: $e');
AppLogger.d('刷新 token 失败: $e');
_user = null;
_errorMessage = e.toString();
notifyListeners();
rethrow;
}
}
/// 设置用户
void setUser(UserModel? user) {
_user = user;
notifyListeners();
}
/// 设置状态
void setState(AuthState state) {
_state = state;
notifyListeners();
}
/// 清除错误
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 清除用户数据
void _clearUserData() {
_user = null;
_errorMessage = null;
_hasRefreshTokenExpired = false;
notifyListeners();
}
/// 处理 RefreshTokenExpiredException
void setRefreshTokenExpired() {
_hasRefreshTokenExpired = true;
notifyListeners();
}
void clearRefreshTokenExpired() {
_hasRefreshTokenExpired = false;
notifyListeners();
}
/// 获取上次登录的邮箱(用于填充输入框)
String? get rememberedEmail {
final server = ServerService.instance.currentServer;
return server?.email;
}
/// 获取上次登录的密码(用于填充输入框)
String? get rememberedPassword {
final server = ServerService.instance.currentServer;
return server?.password;
}
/// 是否记住密码
bool get rememberMe {
final server = ServerService.instance.currentServer;
return server?.rememberMe ?? false;
}
}
@@ -0,0 +1,447 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../core/constants/storage_keys.dart';
import '../../data/models/download_task_model.dart';
import '../../services/download_service.dart';
import '../../services/storage_service.dart';
import '../../core/utils/app_logger.dart';
/// 下载管理Provider
class DownloadManagerProvider extends ChangeNotifier {
final DownloadService _downloadService = DownloadService();
final Map<String, DownloadTaskModel> _tasks = {};
bool _isInitialized = false;
bool _isWifiOnlyEnabled = false;
// 速度追踪:记录每个任务的上次进度更新时间和字节数
final Map<String, DateTime> _lastProgressTime = {};
final Map<String, int> _lastProgressBytes = {};
/// 获取所有下载任务
List<DownloadTaskModel> get tasks => _tasks.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
/// 获取指定状态的任务
List<DownloadTaskModel> getTasksByStatus(DownloadStatus status) {
return tasks.where((task) => task.status == status).toList();
}
/// 下载中的任务数
int get downloadingCount =>
getTasksByStatus(DownloadStatus.downloading).length;
/// 活跃任务数(下载中 + 等待中 + 暂停)
int get activeTaskCount => tasks
.where((t) =>
t.status == DownloadStatus.downloading ||
t.status == DownloadStatus.waiting ||
t.status == DownloadStatus.paused)
.length;
/// WiFi-only 设置
bool get isWifiOnlyEnabled => _isWifiOnlyEnabled;
/// 初始化下载服务
Future<void> initialize() async {
if (_isInitialized) return;
await _downloadService.initialize(callbackHandler: _handleDownloadCallback);
// 加载 WiFi-only 设置
_isWifiOnlyEnabled =
await StorageService.instance.getBool(StorageKeys.downloadWifiOnly) ??
false;
// 从本地存储加载已保存的下载任务
await _loadTasks();
_isInitialized = true;
AppLogger.d('DownloadManagerProvider 初始化完成');
}
/// 更新 WiFi-only 设置,并同步等待中的任务
Future<void> setWifiOnlyEnabled(bool value) async {
_isWifiOnlyEnabled = value;
await StorageService.instance
.setBool(StorageKeys.downloadWifiOnly, value);
// 如果关闭了WiFi-only,需要将等待WiFi的任务重新入队
if (!value) {
for (final task in _tasks.values.toList()) {
if (task.waitingForWifi) {
// 取消当前等待WiFi的任务,重新入队(不需要WiFi)
await _downloadService.cancelDownload(task.id);
_tasks[task.id] = task.copyWith(
status: DownloadStatus.waiting,
waitingForWifi: false,
);
await _saveTasks();
// 重新开始下载
await _downloadService.startDownload(_tasks[task.id]!);
}
}
}
notifyListeners();
}
/// 添加下载任务
Future<DownloadTaskModel?> addDownloadTask({
required String fileName,
required String fileUri,
required int fileSize,
String? savePath,
}) async {
// 如果已存在相同文件的任务,返回null
DownloadTaskModel? existingTask;
for (final task in _tasks.values) {
if (task.fileUri == fileUri) {
existingTask = task;
break;
}
}
if (existingTask != null) {
return null;
}
// 确保下载服务已初始化
await initialize();
// 获取保存路径
if (savePath == null) {
final dir = await _downloadService.getDownloadDirectory();
savePath = '${dir.path}/$fileName';
}
// 创建任务ID
final id = DateTime.now().millisecondsSinceEpoch.toString();
final task = DownloadTaskModel(
id: id,
fileName: fileName,
fileUri: fileUri,
fileSize: fileSize,
savePath: savePath,
status: DownloadStatus.waiting,
);
_tasks[id] = task;
await _saveTasks();
notifyListeners();
// 开始下载
AppLogger.d(
'准备开始下载任务: ${task.id}, 文件: ${task.fileName}, 下载状态: ${task.status}');
final bdTaskId = await _downloadService.startDownload(task);
AppLogger.d('startDownload 返回: bdTaskId=$bdTaskId');
if (bdTaskId == null) {
// 下载失败,更新任务状态
_tasks[id] = task.copyWith(
status: DownloadStatus.failed,
errorMessage: '无法创建下载任务',
);
notifyListeners();
return null;
}
return task;
}
/// 批量添加下载任务
Future<void> addBatchDownloadTasks(List<Map<String, dynamic>> files) async {
await initialize();
final dir = await _downloadService.getDownloadDirectory();
for (final file in files) {
final fileName = file['name'] as String;
final fileUri = file['path'] as String;
final fileSize = file['size'] as int? ?? 0;
await addDownloadTask(
fileName: fileName,
fileUri: fileUri,
fileSize: fileSize,
savePath: '${dir.path}/$fileName',
);
}
}
/// 处理下载回调
void _handleDownloadCallback(
String taskId, DownloadStatus status, int progress) async {
AppLogger.d(
'DownloadManagerProvider._handleDownloadCallback: taskId=$taskId, status=$status, progress=$progress');
// 获取当前任务
final task = _tasks[taskId];
if (task == null) {
AppLogger.d('任务不存在: taskId=$taskId');
return;
}
// 计算下载字节数和速度
final downloadedBytes = (task.fileSize * progress / 100).toInt();
int speed = 0;
final now = DateTime.now();
final lastTime = _lastProgressTime[taskId];
final lastBytes = _lastProgressBytes[taskId] ?? 0;
if (status == DownloadStatus.downloading && lastTime != null) {
final elapsed = now.difference(lastTime).inMilliseconds;
if (elapsed > 0) {
speed = ((downloadedBytes - lastBytes) * 1000 / elapsed).round();
}
}
if (status == DownloadStatus.downloading) {
_lastProgressTime[taskId] = now;
_lastProgressBytes[taskId] = downloadedBytes;
} else {
_lastProgressTime.remove(taskId);
_lastProgressBytes.remove(taskId);
}
// 判断是否在等待WiFi
final waitingForWifi =
status == DownloadStatus.waiting && _isWifiOnlyEnabled;
// 更新任务
final updatedTask = task.copyWith(
status: status,
downloadedBytes: downloadedBytes,
speed: speed,
waitingForWifi: waitingForWifi,
);
// 如果下载完成,设置完成时间
if (status == DownloadStatus.completed) {
_tasks[taskId] = updatedTask.copyWith(
completedAt: DateTime.now(),
speed: 0,
);
} else {
_tasks[taskId] = updatedTask;
}
AppLogger.d('任务已更新: ${_tasks[taskId]!.status}');
await _saveTasks();
notifyListeners();
}
/// 恢复下载
Future<void> resumeDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
await _downloadService.resumeDownload(taskId);
}
}
/// 暂停下载
Future<void> pauseDownload(String taskId) async {
await _downloadService.pauseDownload(taskId);
final task = _tasks[taskId];
if (task != null) {
if (task.status == DownloadStatus.downloading) {
_tasks[taskId] = task.copyWith(
status: DownloadStatus.paused, speed: 0, waitingForWifi: false);
_lastProgressTime.remove(taskId);
_lastProgressBytes.remove(taskId);
await _saveTasks();
notifyListeners();
}
}
}
/// 取消下载
Future<void> cancelDownload(String taskId) async {
await _downloadService.cancelDownload(taskId);
final task = _tasks[taskId];
if (task != null) {
_tasks[taskId] = task.copyWith(
status: DownloadStatus.cancelled, waitingForWifi: false);
await _saveTasks();
notifyListeners();
// 延迟移除任务,同时从存储中删除
Future.delayed(const Duration(seconds: 2), () {
_tasks.remove(taskId);
_downloadService.disposeTask(taskId);
_saveTasks();
notifyListeners();
});
}
}
/// 删除下载任务(包括文件)
Future<void> deleteDownloadTask(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
// 删除已下载的文件
if (task.status == DownloadStatus.completed) {
await _downloadService.deleteDownloadedFile(task.savePath);
}
// 移除任务
_tasks.remove(taskId);
_downloadService.disposeTask(taskId);
await _saveTasks();
notifyListeners();
}
}
/// 重新下载
Future<void> retryDownload(String taskId) async {
final task = _tasks[taskId];
if (task != null) {
// 删除已下载的部分文件
await _downloadService.deleteDownloadedFile(task.savePath);
// 重置任务状态
_tasks[taskId] = task.copyWith(
downloadedBytes: 0,
speed: 0,
status: DownloadStatus.waiting,
errorMessage: null,
completedAt: null,
waitingForWifi: false,
);
_lastProgressTime.remove(taskId);
_lastProgressBytes.remove(taskId);
await _saveTasks();
notifyListeners();
// 重新开始下载
await _downloadService.startDownload(_tasks[taskId]!);
}
}
/// 清空所有已完成的任务
Future<void> clearCompletedTasks() async {
final completedTasks = getTasksByStatus(DownloadStatus.completed);
for (final task in completedTasks) {
await deleteDownloadTask(task.id);
}
}
/// 清空所有失败的任务
Future<void> clearFailedTasks() async {
final failedTasks = getTasksByStatus(DownloadStatus.failed);
for (final task in failedTasks) {
_tasks.remove(task.id);
_downloadService.disposeTask(task.id);
}
await _saveTasks();
notifyListeners();
}
/// 获取任务
DownloadTaskModel? getTask(String taskId) {
return _tasks[taskId];
}
/// 从本地存储加载下载任务
Future<void> _loadTasks() async {
try {
final tasksJson =
await StorageService.instance.getString(StorageKeys.downloadTasks);
if (tasksJson == null || tasksJson.isEmpty) {
AppLogger.d('没有保存的下载任务');
return;
}
final tasksList = jsonDecode(tasksJson) as List<dynamic>;
final loadedTasks = <DownloadTaskModel>[];
final now = DateTime.now();
for (final taskJson in tasksList) {
try {
final task =
DownloadTaskModel.fromJson(taskJson as Map<String, dynamic>);
// 过滤掉已取消的任务(修复4:已取消任务不恢复)
if (task.status == DownloadStatus.cancelled) {
continue;
}
// 如果任务已完成,只保留配置天数内的记录
if (task.status == DownloadStatus.completed) {
if (task.completedAt == null) {
continue;
}
final retentionDays = await StorageService.instance
.getInt(StorageKeys.taskRetentionDays) ??
7;
// retentionDays == -1 表示永不过期
if (retentionDays > 0) {
final daysSinceCompletion =
now.difference(task.completedAt!).inDays;
if (daysSinceCompletion > retentionDays) {
AppLogger.d(
'跳过超过$retentionDays天的已完成任务: ${task.fileName}');
continue;
}
}
}
loadedTasks.add(task);
} catch (e) {
AppLogger.d('解析下载任务失败: $e');
}
}
// 将加载的任务添加到当前任务列表
for (final task in loadedTasks) {
_tasks[task.id] = task;
}
AppLogger.d('从存储加载了 ${loadedTasks.length} 个下载任务');
// 通知 UI 更新
if (loadedTasks.isNotEmpty) {
notifyListeners();
}
// 恢复未完成的任务
for (final task in loadedTasks) {
if (task.status == DownloadStatus.downloading ||
task.status == DownloadStatus.waiting) {
AppLogger.d('恢复下载任务: ${task.fileName}');
// 使用 resumeDownloadAfterRestart 支持断点续传
await _downloadService.resumeDownloadAfterRestart(task);
} else if (task.status == DownloadStatus.paused) {
// 修复5:暂停的任务需要重建 bdTasks 映射,以便继续下载
AppLogger.d('重建暂停任务映射: ${task.fileName}');
await _downloadService.resumeDownloadAfterRestart(task);
// 重建映射后立即暂停,保持任务在暂停状态
await _downloadService.pauseDownload(task.id);
}
}
} catch (e) {
AppLogger.d('加载下载任务失败: $e');
}
}
/// 保存下载任务到本地存储
Future<void> _saveTasks() async {
try {
final tasksList = _tasks.values.map((task) => task.toJson()).toList();
final tasksJson = jsonEncode(tasksList);
await StorageService.instance
.setString(StorageKeys.downloadTasks, tasksJson);
AppLogger.d('已保存 ${_tasks.length} 个下载任务到存储');
} catch (e) {
AppLogger.d('保存下载任务失败: $e');
}
}
@override
void dispose() {
_downloadService.dispose();
super.dispose();
}
}
@@ -0,0 +1,410 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/file_model.dart';
import '../../services/file_service.dart';
import '../../services/thumbnail_service.dart';
import '../../core/utils/app_logger.dart';
import '../../core/utils/file_utils.dart';
/// 文件视图类型
enum FileViewType { list, grid, gallery }
/// 刷新结果
class RefreshResult {
final int added;
final int removed;
final int updated;
const RefreshResult({required this.added, required this.removed, required this.updated});
bool get isUnchanged => added == 0 && removed == 0 && updated == 0;
}
/// 文件管理Provider
class FileManagerProvider extends ChangeNotifier {
String _currentPath = '/';
List<FileModel> _files = [];
List<String> _selectedFiles = [];
FileViewType _viewType = FileViewType.list;
bool _isLoading = false;
bool _hasMore = true;
String? _errorMessage;
String? _contextHint;
String? _highlightPath;
Timer? _highlightTimer;
String get currentPath => _currentPath;
List<FileModel> get files => _files;
List<String> get selectedFiles => _selectedFiles;
FileViewType get viewType => _viewType;
bool get isLoading => _isLoading;
bool get hasMore => _hasMore;
String? get errorMessage => _errorMessage;
String? get contextHint => _contextHint;
bool get hasSelection => _selectedFiles.isNotEmpty;
String? get highlightPath => _highlightPath;
/// 加载文件列表
Future<void> loadFiles({bool refresh = false, Duration timeout = const Duration(seconds: 5)}) async {
if (refresh) {
_selectedFiles.clear();
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().listFiles(
uri: _currentPath,
pageSize: 50,
).timeout(timeout);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
AppLogger.d("获取files列表: $filesData");
setState(() {
_files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
_hasMore = pagination['next_token'] != null;
_contextHint = response['context_hint'] as String?;
});
} on TimeoutException {
setState(() {
_errorMessage = '加载超时,请检查网络后重试';
_hasMore = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_hasMore = false;
});
} finally {
setState(() {
_isLoading = false;
});
}
}
/// 进入文件夹
Future<void> enterFolder(String path) async {
_currentPath = path;
_selectedFiles.clear();
_highlightPath = null;
_highlightTimer?.cancel();
ThumbnailService.instance.clearAll();
await loadFiles();
}
/// 返回上级
Future<void> goBack() async {
if (_currentPath == '/' || _currentPath.isEmpty) return;
final parts = _currentPath.split('/');
if (parts.length > 1) {
parts.removeLast();
_currentPath = parts.join('/');
} else {
_currentPath = '/';
}
_selectedFiles.clear();
_highlightPath = null;
_highlightTimer?.cancel();
ThumbnailService.instance.clearAll();
notifyListeners();
await loadFiles();
}
/// 选择/取消选择文件
void toggleSelection(String path) {
if (_selectedFiles.contains(path)) {
_selectedFiles.remove(path);
} else {
_selectedFiles.add(path);
}
notifyListeners();
}
/// 选择所有
void selectAll() {
_selectedFiles = _files.map((f) => f.path).toList();
notifyListeners();
}
/// 清除选择
void clearSelection() {
_selectedFiles.clear();
notifyListeners();
}
/// 切换视图类型
void setViewType(FileViewType type) {
_viewType = type;
notifyListeners();
}
/// 设置错误信息
void setErrorMessage(String? message) {
_errorMessage = message;
notifyListeners();
}
/// 设置状态
void setState(VoidCallback fn) {
fn();
notifyListeners();
}
/// 删除选中的文件
Future<String?> deleteSelectedFiles() async {
if (_selectedFiles.isEmpty) return null;
try {
AppLogger.d("删除文件: ${_selectedFiles.join(', ')}");
await FileService().deleteFiles(uris: _selectedFiles);
setState(() {
_files.removeWhere((file) => _selectedFiles.contains(file.path));
});
clearSelection();
return null;
} catch (e) {
final error = e.toString();
setErrorMessage(error);
return error;
}
}
/// 创建文件夹
Future<String?> createFolder(String name) async {
try {
String uri;
if (_currentPath == '/' || _currentPath.isEmpty) {
uri = '/$name';
} else {
uri = '$_currentPath/$name';
}
final response = await FileService().createFile(
uri: uri,
type: 'folder',
errOnConflict: true,
);
final newFolder = FileModel.fromJson(response);
setState(() {
_files.insert(0, newFolder);
});
return null;
} catch (e) {
final error = e.toString();
setErrorMessage(error);
return error;
}
}
/// 删除单个文件(增量移除)
Future<String?> deleteFile(String path) async {
try {
await FileService().deleteFiles(uris: [path]);
setState(() {
_files.removeWhere((file) => file.path == path);
_selectedFiles.remove(path);
});
return null;
} catch (e) {
final error = e.toString();
setErrorMessage(error);
return error;
}
}
/// 移动文件(增量更新)
Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async {
try {
await FileService().moveFiles(uris: uris, dst: destination);
clearSelection();
if (!copy) {
// 移动:文件离开当前目录,直接从列表移除
setState(() {
_files.removeWhere((file) => uris.contains(file.path));
});
} else {
// 复制:仅当目标是当前目录时需要刷新
final normalizedDst = FileUtils.toCloudreveUri(destination);
final normalizedCur = FileUtils.toCloudreveUri(_currentPath);
if (normalizedDst == normalizedCur) {
await loadFiles();
}
}
return null;
} catch (e) {
final error = e.toString();
setErrorMessage(error);
return error;
}
}
/// 重命名文件(原地更新,不刷新列表)
Future<String?> renameFile(String path, String newName) async {
try {
final response = await FileService().renameFile(uri: path, newName: newName);
if (response.isEmpty) {
await loadFiles();
return null;
}
final updatedFile = FileModel.fromJson(response);
final index = _files.indexWhere((f) => f.path == path);
if (index != -1) {
setState(() {
_files[index] = updatedFile;
});
}
return null;
} catch (e) {
final error = e.toString();
setErrorMessage(error);
return error;
}
}
/// 通过 URI 获取文件信息并添加到列表(用于上传完成后)
Future<void> addFileByUri(String fileUri) async {
try {
final response = await FileService().getFileInfo(uri: fileUri);
final newFile = FileModel.fromJson(response);
final exists = _files.any((f) => f.id == newFile.id);
if (!exists) {
setState(() {
_files.insert(0, newFile);
});
}
} catch (e) {
AppLogger.d('获取上传文件信息失败: $e');
}
}
/// 高亮指定文件路径(3 秒后自动清除)
void setHighlightPath(String? path) {
_highlightTimer?.cancel();
_highlightPath = path;
notifyListeners();
if (path != null) {
_highlightTimer = Timer(const Duration(seconds: 3), () {
_highlightPath = null;
notifyListeners();
});
}
}
/// 导航到指定文件夹并高亮目标文件
Future<void> navigateAndHighlight(String folderPath, String filePath) async {
_currentPath = folderPath;
_selectedFiles.clear();
_highlightPath = null;
_highlightTimer?.cancel();
await loadFiles();
setHighlightPath(filePath);
}
/// 清空文件列表
void clearFiles() {
setState(() {
_files = [];
_selectedFiles = [];
_currentPath = '/';
_errorMessage = null;
});
}
/// 智能刷新 - 只更新差异部分
Future<RefreshResult> refreshFiles({Duration timeout = const Duration(seconds: 5)}) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().listFiles(
uri: _currentPath,
pageSize: 50,
).timeout(timeout);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
final newFiles = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
final currentMap = <String, FileModel>{};
for (final file in _files) {
currentMap[file.path] = file;
}
final newMap = <String, FileModel>{};
for (final file in newFiles) {
newMap[file.path] = file;
}
int added = 0;
int removed = 0;
int updated = 0;
final updatedFiles = <FileModel>[];
for (final file in newFiles) {
final existingFile = currentMap[file.path];
if (existingFile != null) {
if (existingFile.updatedAt != file.updatedAt ||
existingFile.size != file.size) {
updatedFiles.add(file);
updated++;
} else {
updatedFiles.add(existingFile);
}
} else {
updatedFiles.add(file);
added++;
}
}
for (final file in _files) {
if (!newMap.containsKey(file.path)) {
removed++;
}
}
setState(() {
_files = updatedFiles;
_hasMore = response['pagination']?['next_token'] != null;
_contextHint = response['context_hint'] as String?;
});
return RefreshResult(added: added, removed: removed, updated: updated);
} on TimeoutException {
setState(() {
_errorMessage = '加载超时,请检查网络后重试';
});
return const RefreshResult(added: 0, removed: 0, updated: 0);
} catch (e) {
setState(() {
_errorMessage = e.toString();
});
return const RefreshResult(added: 0, removed: 0, updated: 0);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_highlightTimer?.cancel();
super.dispose();
}
}
@@ -0,0 +1,14 @@
import 'package:flutter/foundation.dart';
class NavigationProvider extends ChangeNotifier {
int _currentIndex = 0; // 默认概览页
int get currentIndex => _currentIndex;
void setIndex(int index) {
if (_currentIndex != index) {
_currentIndex = index;
notifyListeners();
}
}
}
@@ -0,0 +1,249 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../services/storage_service.dart';
/// 主题模式
enum AppThemeMode {
light,
dark,
system,
}
/// 主题Provider - 管理主题模式和主题色
class ThemeProvider extends ChangeNotifier {
AppThemeMode _themeMode = AppThemeMode.system;
Color _seedColor = const Color(0xFF3B82F6);
static const Color lightScaffoldBg = Color(0xFFF8FAFC);
static const Color darkScaffoldBg = Color(0xFF0F172A);
AppThemeMode get themeMode => _themeMode;
Color get seedColor => _seedColor;
bool get isDark => _themeMode == AppThemeMode.dark;
/// 初始化
Future<void> init() async {
await Future.wait([
loadThemeMode(),
loadSeedColor(),
]);
}
/// 加载主题模式
Future<void> loadThemeMode() async {
final savedMode = await StorageService.instance.themeMode;
if (savedMode != null) {
switch (savedMode) {
case 'light':
_themeMode = AppThemeMode.light;
case 'dark':
_themeMode = AppThemeMode.dark;
default:
_themeMode = AppThemeMode.system;
}
}
notifyListeners();
}
/// 加载主题色
Future<void> loadSeedColor() async {
final saved = await StorageService.instance.getString('theme_seed_color');
if (saved != null && saved.isNotEmpty) {
final color = _colorFromHex(saved);
if (color != null) {
_seedColor = color;
notifyListeners();
}
}
}
/// 设置主题模式
Future<void> setThemeMode(AppThemeMode mode) async {
_themeMode = mode;
notifyListeners();
String modeString;
switch (mode) {
case AppThemeMode.light:
modeString = 'light';
case AppThemeMode.dark:
modeString = 'dark';
case AppThemeMode.system:
modeString = 'system';
}
await StorageService.instance.setThemeMode(modeString);
}
/// 设置主题色
Future<void> setSeedColor(Color color) async {
_seedColor = color;
notifyListeners();
await StorageService.instance.setString('theme_seed_color', _colorToHex(color));
}
/// 切换主题
Future<void> toggleTheme() async {
final newMode = isDark ? AppThemeMode.light : AppThemeMode.dark;
await setThemeMode(newMode);
}
/// 构建亮色主题
ThemeData buildLightTheme() {
return _buildTheme(Brightness.light);
}
/// 构建暗色主题
ThemeData buildDarkTheme() {
return _buildTheme(Brightness.dark);
}
ThemeData _buildTheme(Brightness brightness) {
final isLight = brightness == Brightness.light;
final colorScheme = ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: brightness,
);
final bodyColor = isLight ? Colors.black87 : Colors.white;
final displayColor = isLight ? Colors.black87 : Colors.white;
final baseTextTheme = ThemeData(brightness: brightness).textTheme;
var textTheme = baseTextTheme.apply(
bodyColor: bodyColor,
displayColor: displayColor,
fontFamily: _getPlatformFont(),
);
if (_getPlatformFont() == 'NotoSansSC') {
textTheme = textTheme.copyWith(
bodyLarge: textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
bodyMedium: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
bodySmall: textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
titleLarge: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
titleMedium: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
titleSmall: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
labelLarge: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
);
}
return ThemeData(
textTheme: textTheme,
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: isLight ? lightScaffoldBg : darkScaffoldBg,
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: isLight
? lightScaffoldBg.withValues(alpha: 0.85)
: darkScaffoldBg.withValues(alpha: 0.85),
surfaceTintColor: Colors.transparent,
foregroundColor: isLight ? Colors.black87 : Colors.white,
),
cardTheme: CardThemeData(
elevation: 0,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isLight
? Colors.black.withValues(alpha: 0.06)
: Colors.white.withValues(alpha: 0.08),
),
),
color: isLight ? Colors.white : const Color(0xFF1E293B),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
bottomSheetTheme: const BottomSheetThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
backgroundColor: isLight
? lightScaffoldBg.withValues(alpha: 0.9)
: darkScaffoldBg.withValues(alpha: 0.9),
indicatorColor: colorScheme.primary.withValues(alpha: 0.12),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
),
navigationRailTheme: NavigationRailThemeData(
elevation: 0,
backgroundColor: isLight
? lightScaffoldBg
: darkScaffoldBg,
indicatorColor: colorScheme.primary.withValues(alpha: 0.12),
),
);
}
/// Color → hex string (不含alpha)
static String _colorToHex(Color color) {
return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
}
/// hex string → Color
static Color? _colorFromHex(String hex) {
final clean = hex.replaceFirst('#', '');
if (clean.length == 6) {
return Color(int.parse('FF$clean', radix: 16));
}
if (clean.length == 8) {
return Color(int.parse(clean, radix: 16));
}
return null;
}
String? _getPlatformFont() {
if (Platform.isWindows || Platform.isLinux) return 'NotoSansSC';
if (Platform.isMacOS) return 'PingFang SC';
return null;
}
}
@@ -0,0 +1,103 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../data/models/upload_task_model.dart';
import '../../services/upload_service.dart';
/// 上传管理Provider
class UploadManagerProvider extends ChangeNotifier {
final UploadService _uploadService = UploadService.instance;
bool _isInitialized = false;
bool _shouldShowDialog = false;
bool get showUploadDialog => _shouldShowDialog && _uploadService.allTasks.isNotEmpty;
List<UploadTaskModel> get allTasks => _uploadService.allTasks;
List<UploadTaskModel> get activeTasks => _uploadService.activeTasks;
/// 初始化上传管理器
Future<void> initialize() async {
if (_isInitialized) return;
await _uploadService.initialize();
_uploadService.addListener(_onServiceChanged);
_isInitialized = true;
}
void _onServiceChanged() {
notifyListeners();
}
/// 标记应该显示对话框
void markShouldShowDialog() {
_shouldShowDialog = true;
notifyListeners();
}
/// 隐藏对话框
void hideDialog() {
_shouldShowDialog = false;
notifyListeners();
}
/// 开始上传
Future<void> startUpload(
List<File> files,
String targetPath,
) async {
for (final file in files) {
// 构建目标路径 URI
String uri;
if (targetPath.startsWith('cloudreve://my')) {
uri = targetPath;
} else {
// 移除前导斜杠避免重复
String pathPart = targetPath;
if (pathPart.startsWith('/')) {
pathPart = pathPart.substring(1);
}
uri = pathPart.isEmpty ? 'cloudreve://my' : 'cloudreve://my/$pathPart';
}
final task = UploadTaskModel(
id: DateTime.now().millisecondsSinceEpoch.toString() + file.path,
file: file,
fileName: file.uri.pathSegments.last,
fileSize: await file.length(),
targetPath: uri,
);
_uploadService.addTask(task);
// 开始上传任务
_uploadService.startUpload(task);
}
}
/// 取消上传
void cancelUpload(String taskId) {
_uploadService.cancelUpload(taskId);
}
/// 重试上传
void retryUpload(String taskId) {
_uploadService.retryUpload(taskId);
}
/// 删除任务
void removeTask(String taskId) {
_uploadService.removeTask(taskId);
}
/// 清除所有已完成的任务
void clearCompletedTasks() {
_uploadService.clearCompletedTasks();
}
/// 清除失败的任务
void clearFailedTasks() {
_uploadService.clearFailedTasks();
}
@override
void dispose() {
_uploadService.removeListener(_onServiceChanged);
super.dispose();
}
}
@@ -0,0 +1,251 @@
import 'package:flutter/foundation.dart';
import '../../data/models/user_setting_model.dart';
import '../../services/user_setting_service.dart';
import '../../core/utils/app_logger.dart';
/// 用户设置状态
enum UserSettingState { idle, loading, error }
/// 用户设置 Provider
class UserSettingProvider extends ChangeNotifier {
UserSettingState _state = UserSettingState.idle;
UserSettingModel? _settings;
UserCapacityModel? _capacity;
String? _errorMessage;
UserSettingState get state => _state;
UserSettingModel? get settings => _settings;
UserCapacityModel? get capacity => _capacity;
String? get errorMessage => _errorMessage;
bool get isLoading => _state == UserSettingState.loading;
final UserSettingService _service = UserSettingService.instance;
/// 加载用户设置
Future<void> loadSettings() async {
try {
_setState(UserSettingState.loading);
_settings = await _service.getUserSetting();
_setState(UserSettingState.idle);
} catch (e) {
AppLogger.d('加载用户设置失败: $e');
_errorMessage = e.toString();
_setState(UserSettingState.error);
}
}
/// 加载存储用量
Future<void> loadCapacity() async {
try {
_capacity = await _service.getUserCapacity();
notifyListeners();
} catch (e) {
AppLogger.d('加载存储用量失败: $e');
}
}
/// 同时加载设置和容量
Future<void> loadAll() async {
await Future.wait([
loadSettings(),
loadCapacity(),
]);
}
/// 修改昵称
Future<bool> updateNick(String nick) async {
try {
await _service.updateNick(nick);
// 成功后刷新设置
await loadSettings();
return true;
} catch (e) {
AppLogger.d('修改昵称失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 修改主题色
Future<bool> updatePreferredTheme(String themeColor) async {
try {
await _service.updatePreferredTheme(themeColor);
if (_settings != null) {
_settings = _settings!.copyWith(); // 本地无需维护此字段
}
return true;
} catch (e) {
AppLogger.d('修改主题色失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 修改语言
Future<bool> updateLanguage(String language) async {
try {
await _service.updateLanguage(language);
return true;
} catch (e) {
AppLogger.d('修改语言失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 修改密码
Future<bool> changePassword({
required String currentPassword,
required String newPassword,
}) async {
try {
await _service.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
return true;
} catch (e) {
AppLogger.d('修改密码失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 启用2FA
Future<String?> prepare2FA() async {
try {
return await _service.prepare2FA();
} catch (e) {
AppLogger.d('准备启用2FA失败: $e');
_errorMessage = e.toString();
notifyListeners();
return null;
}
}
Future<bool> enable2FA(String code) async {
try {
await _service.enable2FA(code);
await loadSettings();
return true;
} catch (e) {
AppLogger.d('启用2FA失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 禁用2FA
Future<bool> disable2FA(String code) async {
try {
await _service.disable2FA(code);
await loadSettings();
return true;
} catch (e) {
AppLogger.d('禁用2FA失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 更新版本保留设置
Future<bool> updateVersionRetention({
bool? enabled,
List<String>? ext,
int? max,
}) async {
try {
await _service.updateUserSetting(
versionRetentionEnabled: enabled,
versionRetentionExt: ext,
versionRetentionMax: max,
);
await loadSettings();
return true;
} catch (e) {
AppLogger.d('更新版本保留设置失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 更新视图同步
Future<bool> updateViewSync(bool disabled) async {
try {
await _service.updateUserSetting(disableViewSync: disabled);
if (_settings != null) {
_settings = _settings!.copyWith(disableViewSync: disabled);
}
notifyListeners();
return true;
} catch (e) {
AppLogger.d('更新视图同步设置失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 更新分享链接可见性
Future<bool> updateShareLinksInProfile(String value) async {
try {
await _service.updateUserSetting(shareLinksInProfile: value);
if (_settings != null) {
_settings = _settings!.copyWith(shareLinksInProfile: value);
}
notifyListeners();
return true;
} catch (e) {
AppLogger.d('更新分享链接可见性失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 撤销OAuth授权
Future<bool> revokeOAuthGrant(String appId) async {
try {
await _service.revokeOAuthGrant(appId);
await loadSettings();
return true;
} catch (e) {
AppLogger.d('撤销OAuth授权失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 解绑OIDC提供商
Future<bool> unlinkOpenId(int provider) async {
try {
await _service.unlinkOpenId(provider);
await loadSettings();
return true;
} catch (e) {
AppLogger.d('解绑OIDC失败: $e');
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
/// 清除错误
void clearError() {
_errorMessage = null;
notifyListeners();
}
void _setState(UserSettingState state) {
_state = state;
notifyListeners();
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CodeWrapperWidget extends StatefulWidget {
final Widget child;
final String text;
final String language;
const CodeWrapperWidget(this.child, this.text, this.language, {super.key});
@override
State<CodeWrapperWidget> createState() => _PreWrapperState();
}
class _PreWrapperState extends State<CodeWrapperWidget> {
late Widget _switchWidget;
bool hasCopied = false;
@override
void initState() {
super.initState();
_switchWidget = Icon(Icons.copy_rounded, key: UniqueKey());
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Stack(
children: [
widget.child,
Align(
alignment: Alignment.topRight,
child: Container(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.language.isNotEmpty)
SelectionContainer.disabled(
child: Container(
margin: EdgeInsets.only(right: 2),
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
width: 0.5,
color: isDark ? Colors.white : Colors.black)),
child: Text(widget.language),
)),
InkWell(
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: _switchWidget,
),
onTap: () async {
if (hasCopied) return;
await Clipboard.setData(ClipboardData(text: widget.text));
_switchWidget = Icon(Icons.check, key: UniqueKey());
refresh();
Future.delayed(Duration(seconds: 2), () {
hasCopied = false;
_switchWidget =
Icon(Icons.copy_rounded, key: UniqueKey());
refresh();
});
},
),
],
),
),
)
],
);
}
void refresh() {
if (mounted) setState(() {});
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// 桌面端内容宽度约束
/// 屏幕宽度 >= 1000px 时,将子组件内容限制在 maxContentWidth 内居中显示
class DesktopConstrained extends StatelessWidget {
final Widget child;
final double maxContentWidth;
const DesktopConstrained({
super.key,
required this.child,
this.maxContentWidth = 800,
});
static const double desktopBreakpoint = 1000;
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= desktopBreakpoint) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxContentWidth),
child: child,
),
);
}
return child;
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../config/app_config.dart';
class DesktopTitleBar extends StatelessWidget implements PreferredSizeWidget {
const DesktopTitleBar({super.key});
@override
Widget build(BuildContext context) {
return WindowCaption(
brightness: Theme.of(context).brightness,
backgroundColor: Colors.transparent, // 透明背景,露出下面的组件颜色
title: Row(
children: [
// 可以在这里放一个小 Logo
Image.asset(
'assets/icons/tray_icon.png',
width: 20,
height: 20,
),
const SizedBox(width: 10),
const Text(
AppConfig.appName,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'NotoSansSC',
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(32); // Windows 标准标题栏高度
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/download_task_model.dart';
import '../providers/download_manager_provider.dart';
import 'download_progress_item.dart';
/// 下载进度对话框
class DownloadProgressDialog extends StatelessWidget {
const DownloadProgressDialog({super.key});
@override
Widget build(BuildContext context) {
return Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
final tasks = downloadManager.tasks;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300,
// 当没有任务时,最小高度约为2个任务的大小(约160px)
minHeight: tasks.isEmpty ? 160 : 0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题栏
_buildHeader(context),
// 任务列表
Flexible(
child: tasks.isEmpty
? _buildEmptyState(context)
: _buildTaskList(context, downloadManager, tasks),
),
// 底部操作栏
_buildFooter(context, tasks),
],
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_done,
size: 48,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'暂无下载任务',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
);
}
Widget _buildTaskList(
BuildContext context,
DownloadManagerProvider downloadManager,
List<DownloadTaskModel> tasks,
) {
return ListView.separated(
shrinkWrap: true,
itemCount: tasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = tasks[index];
return DownloadProgressItem(
key: ValueKey(task.id),
task: task,
onPause: () => downloadManager.pauseDownload(task.id),
onResume: () => downloadManager.resumeDownload(task.id),
onCancel: () => downloadManager.cancelDownload(task.id),
onDelete: () async {
await downloadManager.deleteDownloadTask(task.id);
},
onRetry: () => downloadManager.retryDownload(task.id),
);
},
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
const Icon(Icons.download, color: Colors.blue),
const SizedBox(width: 12),
const Text(
'下载任务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
final downloadingCount = downloadManager.downloadingCount;
if (downloadingCount > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'下载中: $downloadingCount',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
tooltip: '关闭',
),
],
),
);
}
Widget _buildFooter(BuildContext context, List<DownloadTaskModel> tasks) {
final hasCompleted = tasks.any((t) => t.status == DownloadStatus.completed);
final hasFailed = tasks.any((t) => t.status == DownloadStatus.failed);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
if (hasCompleted)
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
return TextButton.icon(
icon: const Icon(Icons.delete_sweep, size: 18),
label: const Text('清除已完成'),
onPressed: () async {
await downloadManager.clearCompletedTasks();
},
);
},
),
if (hasFailed)
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
return TextButton.icon(
icon: const Icon(Icons.clear_all, size: 18),
label: const Text('清除失败'),
onPressed: () {
downloadManager.clearFailedTasks();
},
);
},
),
const Spacer(),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('关闭'),
),
],
),
);
}
}
/// 显示下载对话框
void showDownloadDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const DownloadProgressDialog(),
barrierDismissible: false,
);
}
@@ -0,0 +1,418 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import '../../data/models/download_task_model.dart';
import '../../services/download_service.dart';
import '../providers/download_manager_provider.dart';
import 'toast_helper.dart';
import '../../core/utils/app_logger.dart';
/// 下载任务列表项
class DownloadProgressItem extends StatelessWidget {
final DownloadTaskModel task;
final VoidCallback? onPause;
final VoidCallback? onResume;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
final VoidCallback? onRetry;
const DownloadProgressItem({
super.key,
required this.task,
this.onPause,
this.onResume,
this.onCancel,
this.onDelete,
this.onRetry,
});
@override
Widget build(BuildContext context) {
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: true,
);
final latestTask = downloadManager.getTask(task.id) ?? task;
final isDownloading = latestTask.status == DownloadStatus.downloading;
final isPaused = latestTask.status == DownloadStatus.paused;
final isFailed = latestTask.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: _getCardColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getBorderColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildStatusIcon(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
_buildStatusRow(context, latestTask),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: _buildActionButtons(context, latestTask),
),
],
),
if (isDownloading || isPaused) ...[
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: isPaused ? null : latestTask.progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
isPaused ? '已暂停' : latestTask.progressText,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${DownloadService.getReadableFileSize(latestTask.downloadedBytes)} / '
'${DownloadService.getReadableFileSize(latestTask.fileSize)}',
style: const TextStyle(fontSize: 12),
),
if (latestTask.speedText.isNotEmpty) ...[
const SizedBox(width: 12),
Text(
latestTask.speedText,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
] else if (isFailed && latestTask.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
latestTask.errorMessage!,
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
);
}
Widget _buildStatusIcon(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final color = _getStatusColor(status, waitingForWifi: waitingForWifi);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getStatusIcon(status, waitingForWifi: waitingForWifi),
size: 18,
color: color,
),
);
}
Widget _buildStatusRow(BuildContext context, DownloadTaskModel task) {
final color = _getStatusColor(task.status, waitingForWifi: task.waitingForWifi);
final isCompleted = task.status == DownloadStatus.completed;
return Row(
children: [
Text(
task.statusText,
style: TextStyle(fontSize: 12, color: color),
),
if (isCompleted) ...[
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
DownloadService.getReadableFileSize(task.fileSize),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
_formatDateTime(task.completedAt!),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
],
);
}
List<Widget> _buildActionButtons(
BuildContext context,
DownloadTaskModel task,
) {
final errorColor = Theme.of(context).colorScheme.error;
switch (task.status) {
case DownloadStatus.waiting:
if (task.waitingForWifi) {
return [
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
}
return [];
case DownloadStatus.downloading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: onPause,
tooltip: '暂停',
),
];
case DownloadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: onResume,
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
case DownloadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: onRetry,
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case DownloadStatus.completed:
final showOpenFolder = !Platform.isAndroid;
return [
IconButton(
icon: const Icon(Icons.open_in_new, size: 20),
onPressed: () => _openDownloadedFile(context, task),
tooltip: '打开',
),
if (showOpenFolder)
IconButton(
icon: const Icon(Icons.folder_open, size: 20),
onPressed: () => _openFileFolder(context, task),
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case DownloadStatus.cancelled:
return [];
}
}
Future<void> checkInstallPermission() async {
if (await Permission.requestInstallPackages.isDenied) {
await Permission.requestInstallPackages.request();
}
}
Future<void> _openDownloadedFile(
BuildContext context,
DownloadTaskModel task,
) async {
final file = File(task.savePath);
if (!await file.exists()) {
if (context.mounted) {
ToastHelper.error('文件不存在:${task.fileName}');
}
return;
}
try {
final ext = task.savePath.toString().split('.').last.toLowerCase();
if (ext == 'apk') {
await checkInstallPermission();
}
OpenResult openResult = await OpenFile.open(task.savePath);
AppLogger.d('下载对话框打开文件结果:${openResult.type}');
if (openResult.type == ResultType.done) {
AppLogger.d('成功打开文件:${task.fileName}');
} else {
if (context.mounted) {
ToastHelper.error(
'无法打开文件:${task.fileName} 错误信息: ${openResult.message}',
);
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件失败:$e');
}
}
}
Future<void> _openFileFolder(
BuildContext context,
DownloadTaskModel task,
) async {
try {
final dir = File(task.savePath).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done && context.mounted) {
ToastHelper.error('无法打开文件夹:${result.message}');
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件夹失败:$e');
}
}
}
IconData _getStatusIcon(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? LucideIcons.wifi : LucideIcons.clock;
case DownloadStatus.downloading:
return LucideIcons.download;
case DownloadStatus.completed:
return LucideIcons.checkCircle2;
case DownloadStatus.paused:
return LucideIcons.pause;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? Colors.blue : Colors.grey;
case DownloadStatus.downloading:
return Colors.blue;
case DownloadStatus.completed:
return Colors.green;
case DownloadStatus.paused:
return Colors.orange;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return Colors.red;
}
}
Color _getCardColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case DownloadStatus.waiting:
if (waitingForWifi) {
return isDark ? Colors.blue.withValues(alpha: 0.08) : Colors.blue.withValues(alpha: 0.05);
}
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
case DownloadStatus.completed:
return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05);
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05);
default:
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
}
}
Color _getBorderColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case DownloadStatus.waiting:
if (waitingForWifi) {
return Colors.blue.withValues(alpha: isDark ? 0.2 : 0.15);
}
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
case DownloadStatus.completed:
return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15);
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15);
default:
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
}
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// 空文件夹状态组件
class EmptyFolderView extends StatelessWidget {
final String currentPath;
const EmptyFolderView({
super.key,
required this.currentPath,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
currentPath == '/' ? '文件夹为空' : '暂无文件',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
if (currentPath == '/') const SizedBox(height: 8),
if (currentPath == '/')
Text(
'点击 + 按钮创建新文件夹',
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
),
],
),
);
}
}
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// 退出提示覆盖层
class ExitHintOverlay {
OverlayEntry? _overlay;
AnimationController? _animationController;
/// 显示退出提示
void show(BuildContext context) {
remove();
_overlay = OverlayEntry(
builder: (overlayContext) => Positioned.fill(
child: Center(
child: Material(
color: Colors.transparent,
child: _ExitHintWidget(
onDismiss: remove,
onAnimationReady: (controller, fadeAnimation) {
_animationController = controller;
controller.forward();
},
),
),
),
),
);
Overlay.of(context).insert(_overlay!);
}
/// 移除退出提示
void remove() {
// 立即移除 overlay
_overlay?.remove();
_overlay = null;
// 停止动画
_animationController?.stop();
// 清理控制器引用
_animationController = null;
}
}
class _ExitHintWidget extends StatefulWidget {
final VoidCallback onDismiss;
final Function(AnimationController controller, Animation<double> fadeAnimation) onAnimationReady;
const _ExitHintWidget({
required this.onDismiss,
required this.onAnimationReady,
});
@override
State<_ExitHintWidget> createState() => _ExitHintWidgetState();
}
class _ExitHintWidgetState extends State<_ExitHintWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
widget.onAnimationReady(_controller, _fadeAnimation);
}
@override
void dispose() {
_controller.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: child,
);
},
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth * 0.65;
return GestureDetector(
onTap: widget.onDismiss,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
constraints: BoxConstraints(maxWidth: cardWidth),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.touch_app_rounded,
color: theme.colorScheme.error,
size: 24,
),
),
const SizedBox(width: 14),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'再次左滑退出应用',
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'点击屏幕任意位置取消',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
],
),
),
),
),
);
},
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
/// 面包屑导航组件
class FileBreadcrumb extends StatelessWidget {
final String currentPath;
final void Function(String path) onPathTap;
const FileBreadcrumb({
super.key,
required this.currentPath,
required this.onPathTap,
});
@override
Widget build(BuildContext context) {
final pathParts = currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
border: Border(
top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5), width: 1),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildBreadcrumbItem(
context,
name: '首页',
path: '/',
icon: LucideIcons.home,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/'),
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(LucideIcons.chevronRight, size: 16, color: theme.hintColor.withValues(alpha: 0.5)),
),
_buildBreadcrumbItem(
context,
name: pathParts[i],
path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
),
],
],
),
),
);
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
required String path,
required IconData? icon,
required Color primaryColor,
required VoidCallback onTap,
}) {
return Container(
height: 36,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
if (icon != null)
Icon(icon, size: 16, color: primaryColor),
if (icon != null) const SizedBox(width: 5),
Text(
name,
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart' hide DateUtils;
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart';
import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_utils.dart';
import 'file_menu_helper.dart';
import 'thumbnail_image.dart';
/// 文件网格项
class FileGridItem extends StatelessWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final VoidCallback? onTap;
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback? onShare;
final VoidCallback? onDelete;
final VoidCallback? onRestore;
final VoidCallback? onInfo;
final bool tapToShowMenu;
final String? contextHint;
const FileGridItem({
super.key,
required this.file,
this.isSelected = false,
this.isHighlighted = false,
this.showCheckbox = false,
this.tapToShowMenu = false,
this.onTap,
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onRename,
this.onMove,
this.onCopy,
this.onShare,
this.onDelete,
this.onRestore,
this.onInfo,
this.contextHint,
});
@override
Widget build(BuildContext context) {
return Builder(
builder: (builderContext) => LayoutBuilder(
builder: (context, constraints) {
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
return _FileGridItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
showCheckbox: showCheckbox,
contextHint: contextHint,
fontSize: fontSize,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(builderContext),
onSelect: onSelect,
onMore: () => _showMenu(builderContext),
);
},
),
);
}
Future<void> _showMenu(BuildContext context) async {
final result = await showFileMenu(
context: context,
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
hasShare: onShare != null,
hasDelete: onDelete != null,
hasRestore: onRestore != null,
hasInfo: onInfo != null,
);
switch (result) {
case FileMenuAction.select:
onSelect?.call();
case FileMenuAction.download:
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
onMove?.call();
case FileMenuAction.copy:
onCopy?.call();
case FileMenuAction.share:
onShare?.call();
case FileMenuAction.delete:
onDelete?.call();
case FileMenuAction.restore:
onRestore?.call();
case FileMenuAction.info:
onInfo?.call();
case null:
break;
}
}
}
class _FileGridItemHover extends StatefulWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final String? contextHint;
final double fontSize;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onSelect;
final VoidCallback? onMore;
final bool tapToShowMenu;
const _FileGridItemHover({
required this.file,
required this.isSelected,
required this.isHighlighted,
required this.showCheckbox,
required this.contextHint,
required this.fontSize,
this.onTap,
this.onLongPress,
this.onSelect,
this.onMore,
this.tapToShowMenu = false,
});
@override
State<_FileGridItemHover> createState() => _FileGridItemHoverState();
}
class _FileGridItemHoverState extends State<_FileGridItemHover> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
// 卡片背景
Color cardBg;
BoxBorder? border;
List<BoxShadow>? shadows;
if (widget.isSelected) {
cardBg = colorScheme.primary.withValues(alpha: 0.06);
border = Border.all(color: colorScheme.primary, width: 2);
} else if (widget.isHighlighted) {
cardBg = colorScheme.primary.withValues(alpha: 0.06);
border = Border.all(color: colorScheme.primary.withValues(alpha: 0.3));
shadows = [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
} else if (_isHovered) {
cardBg = isDark ? const Color(0xFF263548) : const Color(0xFFF1F5F9);
border = Border.all(color: colorScheme.primary.withValues(alpha: 0.2));
shadows = [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
];
} else {
cardBg = colorScheme.surfaceContainerLow;
border = Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: theme.dividerColor.withValues(alpha: 0.15),
);
}
// 文字颜色
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap,
onLongPress: widget.onLongPress,
onSecondaryTap: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(8),
border: border,
boxShadow: shadows,
),
padding: const EdgeInsets.all(6),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 图标区
Expanded(
child: widget.showCheckbox
? Center(
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
)
: _buildIconArea(context),
),
const SizedBox(height: 6),
// 文字区:左对齐
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:文件名
Text(
_truncateFileName(widget.file.name),
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w500,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 第二行:类型 | 大小
Text(
widget.file.isFolder
? FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: true)
: '${FileIconUtils.getFileTypeLabel(widget.file.name)} | ${DateUtils.formatFileSize(widget.file.size)}',
style: TextStyle(
fontSize: widget.fontSize * 0.85,
color: theme.hintColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
// 第三行:修改时间
Text(
DateUtils.formatDateTime(widget.file.updatedAt),
style: TextStyle(
fontSize: widget.fontSize * 0.8,
color: theme.hintColor.withValues(alpha: 0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// Hover 操作按钮
if (_isHovered && !widget.showCheckbox)
Positioned(
top: 0,
right: 0,
child: Material(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: widget.onMore,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(LucideIcons.moreVertical, size: 14, color: colorScheme.primary),
),
),
),
),
],
),
),
),
);
}
String _truncateFileName(String name) {
const maxChars = 20;
if (name.length <= maxChars) return name;
final dotIndex = name.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < name.length - 1) {
final prefix = name.substring(0, 8);
final extension = name.substring(dotIndex);
final middleLength = maxChars - prefix.length - extension.length - 3;
if (middleLength > 0) return '$prefix...$extension';
}
final half = (maxChars - 3) ~/ 2;
return '${name.substring(0, half)}...${name.substring(name.length - half)}';
}
Widget _buildIconArea(BuildContext context) {
final file = widget.file;
final ext = FileUtils.getFileExtension(file.name);
final isThumbnailable = !file.isFolder
&& FileUtils.isImageFile(file.name)
&& ext != 'svg';
if (!isThumbnailable) {
return Center(
child: FileIconUtils.buildIconWidget(
context: context,
file: file,
size: 40,
iconSize: 22,
borderRadius: 10,
),
);
}
return ThumbnailImage(
file: file,
contextHint: widget.contextHint,
borderRadius: 10,
);
}
}
@@ -0,0 +1,670 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_type_utils.dart';
import '../../services/file_service.dart';
import '../../router/app_router.dart';
import 'toast_helper.dart';
/// 文件/文件夹详情(右侧抽屉)
class FileInfoPanel extends StatefulWidget {
final FileModel file;
const FileInfoPanel({super.key, required this.file});
/// 在指定 context 的 Scaffold 上打开右侧抽屉
static void show(BuildContext context, FileModel file) {
Scaffold.of(context).openEndDrawer();
}
@override
State<FileInfoPanel> createState() => _FileInfoPanelState();
}
class _FileInfoPanelState extends State<FileInfoPanel> {
FileInfoModel? _fileInfo;
bool _isLoading = true;
bool _isCalculatingFolder = false;
bool _isVersionLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadFileInfo();
}
Future<void> _loadFileInfo() async {
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
extended: widget.file.isFile,
folderSummary: false,
);
if (mounted) {
setState(() {
_fileInfo = FileInfoModel.fromJson(response);
_isLoading = false;
_isVersionLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Future<void> _calculateFolderSize() async {
setState(() => _isCalculatingFolder = true);
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
extended: true,
folderSummary: true,
);
if (mounted) {
setState(() {
_fileInfo = FileInfoModel.fromJson(response);
_isCalculatingFolder = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isCalculatingFolder = false);
ToastHelper.failure('计算文件夹大小失败: $e');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Drawer(
child: SafeArea(
right: false,
child: Column(
children: [
Container(
padding: const EdgeInsets.only(
left: 16,
right: 8,
top: 8,
bottom: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
),
),
child: Row(
children: [
FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 32,
iconSize: 18,
borderRadius: 8,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.file.name,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildError(theme)
: _buildContent(theme, colorScheme),
),
],
),
),
);
}
Widget _buildError(ThemeData theme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 12),
Text('加载失败', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(_error ?? '', style: TextStyle(color: theme.hintColor, fontSize: 12), textAlign: TextAlign.center),
),
const SizedBox(height: 12),
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
],
),
);
}
Widget _buildContent(ThemeData theme, ColorScheme colorScheme) {
final file = _fileInfo?.file ?? widget.file;
final typeLabel = file.isFolder
? '文件夹'
: FileIconUtils.getFileTypeLabel(file.name);
final extendedInfo = _fileInfo?.extendedInfo;
final versionEntities = extendedInfo?.entities
?.where((e) => e.type == 0)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 类型标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
typeLabel,
style: TextStyle(
fontSize: 12,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 16),
// 基本信息
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)),
if (file.isFile)
_buildInfoRow(
LucideIcons.hardDrive,
'大小',
date_utils.DateUtils.formatFileSize(file.size),
),
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(file.createdAt)),
_buildInfoRow(LucideIcons.calendar, '修改时间', date_utils.DateUtils.formatDateTime(file.updatedAt)),
if (file.owned != null)
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''),
// 文件扩展信息
if (file.isFile && extendedInfo != null) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text('扩展信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
if (file.primaryEntity != null)
_buildInfoRow(LucideIcons.hash, '主版本', file.primaryEntity!),
if (extendedInfo.storagePolicy != null)
_buildInfoRow(
LucideIcons.server,
'存储策略',
'${extendedInfo.storagePolicy!.name} (${extendedInfo.storagePolicy!.type})',
),
if (extendedInfo.storageUsed != null)
_buildInfoRow(
LucideIcons.database,
'总占用',
date_utils.DateUtils.formatFileSize(extendedInfo.storageUsed!),
),
],
// 版本历史
if (file.isFile && versionEntities.isNotEmpty) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
_buildVersionSection(theme, colorScheme, versionEntities),
],
// 文件夹信息
if (file.isFolder) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text('文件夹信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_buildFolderSummary(theme, colorScheme),
],
],
),
);
}
Widget _buildVersionSection(
ThemeData theme,
ColorScheme colorScheme,
List<EntityModel> entities,
) {
final file = _fileInfo!.file;
final primaryEntity = file.primaryEntity;
final isPreviewable = FileTypeUtils.isPreviewable(file.name);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${entities.length}',
style: TextStyle(
fontSize: 11,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
...entities.asMap().entries.map((entry) {
final index = entry.key;
final entity = entry.value;
final isCurrent = entity.id == primaryEntity;
final versionNumber = entities.length - index;
return _buildVersionItem(
theme,
colorScheme,
entity: entity,
versionNumber: versionNumber,
isCurrent: isCurrent,
isPreviewable: isPreviewable,
file: file,
);
}),
],
);
}
Widget _buildVersionItem(
ThemeData theme,
ColorScheme colorScheme, {
required EntityModel entity,
required int versionNumber,
required bool isCurrent,
required bool isPreviewable,
required FileModel file,
}) {
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
final createdBy = entity.createdBy?.nickname ?? '未知';
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
decoration: BoxDecoration(
color: isCurrent
? colorScheme.primaryContainer.withValues(alpha: 0.15)
: null,
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.15)),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 380;
final actionButtons = <Widget>[
if (isPreviewable)
_buildVersionActionButton(
icon: LucideIcons.externalLink,
tooltip: '打开',
onPressed: _isVersionLoading ? null : () => _openVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.download,
tooltip: '下载',
onPressed: _isVersionLoading ? null : () => _downloadVersion(entity),
),
if (!isCurrent) ...[
_buildVersionActionButton(
icon: LucideIcons.pin,
tooltip: '设为当前版本',
onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.trash2,
tooltip: '删除',
color: colorScheme.error,
onPressed: _isVersionLoading ? null : () => _deleteVersion(entity),
),
],
];
final versionBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isCurrent
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
isCurrent ? '当前' : 'v$versionNumber',
style: TextStyle(
fontSize: 11,
color: isCurrent
? colorScheme.onPrimaryContainer
: theme.hintColor,
fontWeight: FontWeight.w600,
),
),
);
final versionInfo = Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => _showVersionDetail(entity),
onLongPress: () => _showVersionDetail(entity),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$shortId · ${date_utils.DateUtils.formatFileSize(entity.size)}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
Text(
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
style: const TextStyle(fontSize: 11, color: null)
.copyWith(color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isNarrow) ...[
const SizedBox(height: 4),
Row(children: actionButtons),
],
],
),
);
if (isNarrow) {
return Row(
children: [versionBadge, const SizedBox(width: 8), versionInfo],
);
}
return Row(
children: [
versionBadge,
const SizedBox(width: 8),
versionInfo,
...actionButtons,
],
);
},
),
);
}
void _showVersionDetail(EntityModel entity) {
final createdBy = entity.createdBy;
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('版本详情'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(LucideIcons.hash, 'ID', entity.id),
_buildInfoRow(LucideIcons.hardDrive, '大小', date_utils.DateUtils.formatFileSize(entity.size)),
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(entity.createdAt)),
if (createdBy != null) ...[
_buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname),
_buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id),
],
if (entity.storagePolicy != null)
_buildInfoRow(LucideIcons.server, '存储策略', '${entity.storagePolicy!.name} (${entity.storagePolicy!.type})'),
if (entity.encryptedWith != null)
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
Widget _buildVersionActionButton({
required IconData icon,
required String tooltip,
Color? color,
required VoidCallback? onPressed,
}) {
return IconButton(
icon: Icon(icon, size: 16, color: color),
onPressed: onPressed,
tooltip: tooltip,
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
);
}
// ─── 版本操作 ───
void _openVersion(EntityModel entity) {
final file = _fileInfo!.file;
if (!FileTypeUtils.isPreviewable(file.name)) {
ToastHelper.info('暂不支持预览此文件类型');
return;
}
final args = {'file': file, 'entityId': entity.id};
if (FileTypeUtils.isImage(file.name)) {
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: args);
} else if (FileTypeUtils.isPdf(file.name)) {
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: args);
} else if (FileTypeUtils.isVideo(file.name)) {
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: args);
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: args);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args);
}
}
Future<void> _downloadVersion(EntityModel entity) async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
entity: entity.id,
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
if (mounted) ToastHelper.error('无法打开下载链接');
}
} else {
if (mounted) ToastHelper.error('获取下载链接失败');
}
} catch (e) {
if (mounted) ToastHelper.failure('获取下载链接失败: $e');
}
}
Future<void> _setCurrentVersion(EntityModel entity) async {
setState(() => _isVersionLoading = true);
try {
await FileService().setFileVersion(
uri: widget.file.relativePath,
version: entity.id,
);
if (mounted) {
ToastHelper.success('已设为当前版本');
_loadFileInfo();
}
} catch (e) {
if (mounted) {
setState(() => _isVersionLoading = false);
ToastHelper.failure('操作失败: $e');
}
}
}
Future<void> _deleteVersion(EntityModel entity) async {
final colorScheme = Theme.of(context).colorScheme;
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除版本'),
content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
setState(() => _isVersionLoading = true);
try {
await FileService().deleteFileVersion(
uri: widget.file.relativePath,
version: entity.id,
);
if (mounted) {
ToastHelper.success('版本已删除');
_loadFileInfo();
}
} catch (e) {
if (mounted) {
setState(() => _isVersionLoading = false);
ToastHelper.failure('删除失败: $e');
}
}
}
// ─── 文件夹摘要 ───
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
final summary = _fileInfo?.folderSummary;
if (summary != null) {
return Column(
children: [
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
_buildInfoRow(LucideIcons.hardDrive, '总大小', date_utils.DateUtils.formatFileSize(summary.size)),
if (!summary.completed)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(LucideIcons.alertCircle, size: 14, color: theme.colorScheme.error),
const SizedBox(width: 6),
Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)),
],
),
),
const SizedBox(height: 8),
Text(
'计算于 ${date_utils.DateUtils.formatDateTime(summary.calculatedAt)}',
style: TextStyle(fontSize: 11, color: theme.hintColor),
),
],
);
}
return _isCalculatingFolder
? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _calculateFolderSize,
icon: const Icon(LucideIcons.calculator, size: 16),
label: const Text('计算文件夹大小'),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: theme.hintColor),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)),
),
Expanded(
child: SelectableText(
value,
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// 文件列表表头(桌面端)
class FileListHeader extends StatelessWidget {
final bool showCheckbox;
const FileListHeader({super.key, this.showCheckbox = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = TextStyle(
color: theme.hintColor,
fontSize: 12,
fontWeight: FontWeight.w500,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
),
),
child: Row(
children: [
if (showCheckbox) const SizedBox(width: 40),
// 图标占位
const SizedBox(width: 36 + 16),
Expanded(flex: 5, child: Text('名称', style: style)),
Expanded(flex: 2, child: Text('修改日期', style: style)),
Expanded(flex: 1, child: Text('大小', style: style)),
],
),
);
}
}
@@ -0,0 +1,385 @@
import 'package:flutter/material.dart' hide DateUtils;
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart';
import '../../core/utils/file_icon_utils.dart';
import '../../services/file_service.dart';
import 'file_menu_helper.dart';
/// 文件列表项
class FileListItem extends StatelessWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final int index;
final bool isDesktop;
final VoidCallback? onTap;
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback? onShare;
final VoidCallback? onDelete;
final VoidCallback? onRestore;
final VoidCallback? onInfo;
final bool tapToShowMenu;
const FileListItem({
super.key,
required this.file,
this.isSelected = false,
this.isHighlighted = false,
this.showCheckbox = false,
this.index = 0,
this.isDesktop = true,
this.tapToShowMenu = false,
this.onTap,
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onRename,
this.onMove,
this.onCopy,
this.onShare,
this.onDelete,
this.onRestore,
this.onInfo,
});
@override
Widget build(BuildContext context) {
return _FileListItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
index: index,
isDesktop: isDesktop,
showCheckbox: showCheckbox,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(context),
onSelect: onSelect,
);
}
Future<void> _showMenu(BuildContext context) async {
final result = await showFileMenu(
context: context,
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
hasShare: onShare != null,
hasDelete: onDelete != null,
hasRestore: onRestore != null,
hasInfo: onInfo != null,
);
switch (result) {
case FileMenuAction.select:
onSelect?.call();
case FileMenuAction.download:
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
onMove?.call();
case FileMenuAction.copy:
onCopy?.call();
case FileMenuAction.share:
onShare?.call();
case FileMenuAction.delete:
onDelete?.call();
case FileMenuAction.restore:
onRestore?.call();
case FileMenuAction.info:
onInfo?.call();
case null:
break;
}
}
}
class _FileListItemHover extends StatefulWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final int index;
final bool isDesktop;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool showCheckbox;
final VoidCallback? onSelect;
final bool tapToShowMenu;
const _FileListItemHover({
required this.file,
required this.isSelected,
required this.isHighlighted,
required this.index,
required this.isDesktop,
this.onTap,
this.onLongPress,
required this.showCheckbox,
this.onSelect,
this.tapToShowMenu = false,
});
@override
State<_FileListItemHover> createState() => _FileListItemHoverState();
}
class _FileListItemHoverState extends State<_FileListItemHover> {
bool _isHovered = false;
String? _folderSizeText;
bool _isCalculatingFolder = false;
Future<void> _calculateFolderSize() async {
setState(() => _isCalculatingFolder = true);
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
folderSummary: true,
);
final summary = response['folder_summary'];
if (summary is Map<String, dynamic> && summary.containsKey('size')) {
if (mounted) {
setState(() {
_folderSizeText = DateUtils.formatFileSize(summary['size'] as int);
_isCalculatingFolder = false;
});
}
} else {
if (mounted) setState(() => _isCalculatingFolder = false);
}
} catch (_) {
if (mounted) setState(() => _isCalculatingFolder = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Color bgColor;
if (widget.isSelected) {
bgColor = colorScheme.primary.withValues(alpha: 0.08);
} else if (widget.isHighlighted) {
bgColor = colorScheme.primary.withValues(alpha: 0.06);
} else if (_isHovered) {
bgColor = colorScheme.primary.withValues(alpha: 0.05);
} else if (widget.index.isOdd) {
bgColor = colorScheme.surfaceContainerLow;
} else {
bgColor = colorScheme.surface;
}
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap,
onLongPress: widget.onLongPress,
onSecondaryTap: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
margin: const EdgeInsets.symmetric(vertical: 1, horizontal: 4),
padding: EdgeInsets.symmetric(
horizontal: widget.isDesktop ? 24 : 16,
vertical: 8,
),
child: widget.isDesktop
? _buildDesktopRow(context)
: _buildMobileRow(context),
),
),
);
}
Widget _buildSizeCell(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (!widget.file.isFolder) {
return Text(
DateUtils.formatFileSize(widget.file.size),
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
// 文件夹:已计算 -> 显示大小,未计算 -> 小按钮
if (_folderSizeText != null) {
return Text(
_folderSizeText!,
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return _buildCalcButton(context, colorScheme);
}
Widget _buildCalcButton(BuildContext context, ColorScheme colorScheme) {
if (_isCalculatingFolder) {
return SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.primary,
),
);
}
return InkWell(
onTap: _calculateFolderSize,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.calculator, size: 11, color: colorScheme.primary),
const SizedBox(width: 3),
Text(
'计算',
style: TextStyle(fontSize: 11, color: colorScheme.primary, fontWeight: FontWeight.w500),
),
],
),
),
);
}
/// 桌面端:三列对齐 Row
Widget _buildDesktopRow(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
return Row(
children: [
if (widget.showCheckbox)
SizedBox(
width: 40,
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
FileIconUtils.buildIconWidget(context: context, file: widget.file),
const SizedBox(width: 16),
Expanded(
flex: 5,
child: Text(
widget.file.name,
style: TextStyle(
fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.normal,
fontSize: 14,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 2,
child: Text(
DateUtils.formatDateTime(widget.file.updatedAt),
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 1,
child: _buildSizeCell(context),
),
],
);
}
/// 窄屏端:两行紧凑布局
Widget _buildMobileRow(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
// 构建第二行内容
final typeLabel = FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: widget.file.isFolder);
final dateStr = DateUtils.formatDateTime(widget.file.updatedAt);
return Row(
children: [
if (widget.showCheckbox)
SizedBox(
width: 40,
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
FileIconUtils.buildIconWidget(context: context, file: widget.file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 第二行:文件夹显示 计算按钮,文件显示类型|大小|日期
if (widget.file.isFolder)
Row(
children: [
Text('$typeLabel | $dateStr', style: TextStyle(fontSize: 12, color: theme.hintColor)),
const SizedBox(width: 6),
if (_folderSizeText != null)
Text('| $_folderSizeText', style: TextStyle(fontSize: 12, color: theme.hintColor))
else
_buildCalcButton(context, colorScheme),
],
)
else
Text(
'$typeLabel | ${DateUtils.formatFileSize(widget.file.size)} | $dateStr',
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/app_logger.dart';
/// 文件菜单选项
enum FileMenuAction {
select,
download,
openInBrowser,
rename,
move,
copy,
share,
info,
delete,
restore,
}
/// 显示文件菜单
Future<FileMenuAction?> showFileMenu({
required BuildContext context,
required bool hasSelect,
required bool hasDownload,
required bool hasOpenInBrowser,
required bool hasRename,
required bool hasMove,
required bool hasCopy,
required bool hasShare,
required bool hasDelete,
required bool hasRestore,
bool hasInfo = false,
}) async {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) {
AppLogger.d('showFileMenu: renderBox is null');
return null;
}
final offset = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
// 计算菜单位置,居中显示
final centerX = offset.dx + size.width / 2;
final position = RelativeRect.fromLTRB(
centerX - 120, // 菜单宽度约240,居中显示
offset.dy + size.height / 2,
centerX + 120,
offset.dy + size.height / 2,
);
AppLogger.d('showFileMenu: widget offset: $offset, size: $size, center: $centerX');
final result = await showMenu<FileMenuAction>(
context: context,
position: position,
items: <PopupMenuEntry<FileMenuAction>>[
if (hasSelect)
const PopupMenuItem(
value: FileMenuAction.select,
child: Row(
children: [
Icon(Icons.check_circle_outline, size: 20),
SizedBox(width: 12),
Text('选择'),
],
),
),
if (hasDownload)
const PopupMenuItem(
value: FileMenuAction.download,
child: Row(
children: [
Icon(Icons.download, size: 20),
SizedBox(width: 12),
Text('下载'),
],
),
),
if (hasOpenInBrowser)
const PopupMenuItem(
value: FileMenuAction.openInBrowser,
child: Row(
children: [
Icon(Icons.open_in_browser, size: 20),
SizedBox(width: 12),
Text('在浏览器中打开'),
],
),
),
if (hasRename)
const PopupMenuItem(
value: FileMenuAction.rename,
child: Row(
children: [
Icon(Icons.edit, size: 20),
SizedBox(width: 12),
Text('重命名'),
],
),
),
if (hasMove)
const PopupMenuItem(
value: FileMenuAction.move,
child: Row(
children: [
Icon(Icons.drive_file_move, size: 20),
SizedBox(width: 12),
Text('移动'),
],
),
),
if (hasCopy)
const PopupMenuItem(
value: FileMenuAction.copy,
child: Row(
children: [
Icon(Icons.copy, size: 20),
SizedBox(width: 12),
Text('复制'),
],
),
),
if (hasShare)
const PopupMenuItem(
value: FileMenuAction.share,
child: Row(
children: [
Icon(Icons.share, size: 20),
SizedBox(width: 12),
Text('分享'),
],
),
),
if (hasInfo)
const PopupMenuItem(
value: FileMenuAction.info,
child: Row(
children: [
Icon(LucideIcons.info, size: 20),
SizedBox(width: 12),
Text('详情'),
],
),
),
if (hasDelete)
const PopupMenuItem(
value: FileMenuAction.delete,
child: Row(
children: [
Icon(Icons.delete, size: 20, color: Colors.red),
SizedBox(width: 12),
Text('删除', style: TextStyle(color: Colors.red)),
],
),
),
if (hasRestore)
const PopupMenuItem(
value: FileMenuAction.restore,
child: Row(
children: [
Icon(Icons.restore, size: 20),
SizedBox(width: 12),
Text('恢复'),
],
),
),
],
);
AppLogger.d('showFileMenu: selected value: $result');
return result;
}
@@ -0,0 +1,598 @@
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/share_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/file_manager_provider.dart';
import 'folder_picker.dart';
import 'glassmorphism_container.dart';
import 'toast_helper.dart';
/// 文件操作对话框工具类
class FileOperationDialogs {
/// 显示创建文件夹对话框(毛玻璃风格)
static Future<void> showCreateDialog(
BuildContext context,
FileManagerProvider fileManager,
) async {
final controller = TextEditingController();
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '创建文件夹',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.folderPlus, '创建文件夹'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '文件夹名称',
prefixIcon: const Icon(LucideIcons.folder, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '创建',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
final error = await fileManager.createFolder(controller.text);
if (error != null && context.mounted) {
ToastHelper.failure('创建文件夹失败: $error');
} else if (context.mounted) {
ToastHelper.success('文件夹创建成功');
}
}
}
/// 显示重命名对话框(毛玻璃风格)
static Future<void> showRenameDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final controller = TextEditingController(text: file.name);
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '重命名',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.pencil, '重命名'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '新名称',
prefixIcon: const Icon(LucideIcons.edit3, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '确定',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
await fileManager.renameFile(file.path, controller.text);
}
}
/// 显示删除确认对话框(多个文件,毛玻璃风格)
static Future<void> showDeleteConfirmation(
BuildContext context,
FileManagerProvider fileManager,
List<String> filePaths,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除这 ${filePaths.length} 个文件吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteSelectedFiles();
if (error != null && context.mounted) {
ToastHelper.failure('删除失败: $error');
} else if (context.mounted) {
ToastHelper.success('删除成功');
}
}
}
/// 显示删除确认对话框(单个文件,毛玻璃风格)
static Future<void> showDeleteSingleConfirmation(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除文件 "${file.name}" 吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteFile(file.path);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('删除失败: $error');
} else {
ToastHelper.success('删除成功');
}
}
}
}
/// 显示移动/复制文件对话框
static Future<void> showMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制文件' : '移动文件'),
content: SizedBox(
width: 300,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
[file.path],
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示多选移动/复制文件对话框
static Future<void> showBatchMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
List<String> uris,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制 ${uris.length} 个文件' : '移动 ${uris.length} 个文件'),
content: SizedBox(
width: 300,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
uris,
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示创建分享对话框
static Future<void> showShareDialog(
BuildContext context,
FileModel file,
) async {
final passwordController = TextEditingController();
final expireDaysController = TextEditingController(text: '7');
bool isPrivate = true;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setState) {
return AlertDialog(
title: const Text('创建分享'),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: '文件名',
prefixIcon: Icon(Icons.description),
),
controller: TextEditingController(text: file.name),
enabled: false,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('密码保护'),
subtitle: const Text('需要密码才能访问'),
value: isPrivate,
onChanged: (value) {
setState(() {
isPrivate = value;
});
},
),
if (isPrivate)
TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: '分享密码',
hintText: '留空则自动生成',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 16),
TextField(
controller: expireDaysController,
decoration: const InputDecoration(
labelText: '有效期(天)',
hintText: '留空则永久有效',
prefixIcon: Icon(Icons.timer),
suffixText: '',
),
keyboardType: TextInputType.number,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('创建'),
),
],
);
},
),
);
if (confirmed == true) {
final expireDaysText = expireDaysController.text.trim();
final expireDays = expireDaysText.isEmpty
? null
: int.tryParse(expireDaysText);
final expireSeconds = expireDays != null
? expireDays * 24 * 60 * 60
: null;
try {
final shareUrl = await ShareService().createShare(
uri: file.path,
isPrivate: isPrivate,
password: isPrivate
? (passwordController.text.isEmpty ? null : passwordController.text)
: null,
expire: expireSeconds,
);
if (context.mounted) {
ToastHelper.success('分享创建成功');
if (context.mounted) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('分享链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shareUrl, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.copy, size: 16),
label: const Text('复制到剪贴板'),
onPressed: () {
Clipboard.setData(ClipboardData(text: shareUrl));
Navigator.of(dialogContext).pop();
ToastHelper.success('已复制到剪贴板');
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.failure('分享创建失败: $e');
}
}
}
}
// ─── 内部工具方法 ───
static Widget _buildDialogTitle(BuildContext context, IconData icon, String title) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
static Widget _buildDialogActions(
BuildContext context, {
required VoidCallback onCancel,
required VoidCallback onConfirm,
required String confirmLabel,
bool isDestructive = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: onCancel,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: isDestructive ? colorScheme.error : null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: Text(confirmLabel),
),
],
),
);
}
static Future<bool?> _showConfirmDialog(
BuildContext context, {
required IconData icon,
required String title,
required String message,
required String confirmLabel,
bool isDestructive = false,
}) {
return showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
final theme = Theme.of(context);
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, icon, title),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: Text(message, style: theme.textTheme.bodyMedium),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: confirmLabel,
isDestructive: isDestructive,
),
],
),
),
),
),
),
);
},
);
}
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:flutter/material.dart';
import '../../core/utils/app_logger.dart';
/// 文件夹选择器
class FolderPicker extends StatefulWidget {
final String currentPath;
final void Function(String path) onFolderSelected;
final int? maxVisibleItems;
const FolderPicker({
super.key,
required this.currentPath,
required this.onFolderSelected,
this.maxVisibleItems,
});
@override
State<FolderPicker> createState() => _FolderPickerState();
}
class _FolderPickerState extends State<FolderPicker> {
String _currentPath = '/';
List<FileModel> _folders = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_currentPath = widget.currentPath;
_loadFolders();
}
Future<void> _loadFolders() async {
setState(() {
_isLoading = true;
});
try {
final response = await FileService().listFiles(
uri: _currentPath,
pageSize: 100,
);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
setState(() {
_folders = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.where((f) => f.isFolder)
.toList();
});
} catch (e) {
AppLogger.d('加载文件夹失败: $e');
} finally {
setState(() {
_isLoading = false;
});
}
}
void _enterFolder(FileModel folder) {
setState(() {
_currentPath = folder.path;
});
_loadFolders();
}
@override
Widget build(BuildContext context) {
final primaryColor = Theme.of(context).colorScheme.primary;
final maxHeight = MediaQuery.of(context).size.height * 0.5;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBreadcrumb(context, primaryColor),
const Divider(height: 1),
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: widget.maxVisibleItems != null
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight)
: maxHeight,
),
child: _buildListContent(context, primaryColor),
),
),
],
);
}
Widget _buildListContent(BuildContext context, Color primaryColor) {
if (_isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
);
}
if (_folders.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < _folders.length; i++) ...[
if (i > 0) const Divider(height: 1, indent: 56, endIndent: 16),
_buildFolderItem(_folders[i], primaryColor),
],
],
),
);
}
Widget _buildFolderItem(FileModel folder, Color primaryColor) {
return InkWell(
onTap: () => _enterFolder(folder),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.folder, color: primaryColor, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
],
),
),
);
}
Widget _buildBreadcrumb(BuildContext context, Color primaryColor) {
final pathParts = _currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildBreadcrumbItem(
context,
name: '首页',
path: '/',
isLast: pathParts.isEmpty,
primaryColor: primaryColor,
),
...pathParts.asMap().entries.expand((entry) {
final index = entry.key;
final part = entry.value;
final path = '/${pathParts.sublist(0, index + 1).join('/')}';
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.chevron_right,
size: 16,
color: Colors.grey.shade400,
),
),
_buildBreadcrumbItem(
context,
name: part,
path: path,
isLast: index == pathParts.length - 1,
primaryColor: primaryColor,
),
];
}),
],
),
),
),
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () {
final relative = _currentPath.startsWith('cloudreve://my')
? _currentPath.replaceFirst('cloudreve://my', '')
: _currentPath;
widget.onFolderSelected(relative.isEmpty ? '/' : relative);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('选择'),
),
],
),
),
const Divider(height: 1),
],
);
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
required String path,
required bool isLast,
required Color primaryColor,
}) {
return InkWell(
onTap: isLast
? null
: () {
setState(() {
_currentPath = path;
});
_loadFolders();
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isLast
? primaryColor.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (name == '首页')
Icon(
Icons.home_filled,
size: 16,
color: isLast ? primaryColor : Colors.grey.shade600,
),
if (name == '首页') const SizedBox(width: 6),
Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: isLast ? FontWeight.w600 : FontWeight.w500,
color: isLast ? primaryColor : Colors.grey.shade700,
),
),
],
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import '../providers/file_manager_provider.dart';
import 'exit_hint_overlay.dart';
import '../../services/desktop_service.dart';
/// 手势处理 Mixin
mixin GestureHandlerMixin<T extends StatefulWidget> on State<T> {
DateTime? _lastSwipeTime;
final ExitHintOverlay _exitHintOverlay = ExitHintOverlay();
/// 处理滑动手势
void handleSwipe(
BuildContext context,
FileManagerProvider fileManager,
DragEndDetails details,
) {
if (details.primaryVelocity == null) return;
// 从右往左滑(velocity < 0):返回或退出
if (details.primaryVelocity! < 0) {
if (fileManager.currentPath == '/') {
checkExitApp(context);
} else {
navigateBack(context, fileManager);
}
}
}
/// 处理返回键
Future<void> handleBackPress(
BuildContext context,
FileManagerProvider fileManager,
) async {
if (fileManager.currentPath == '/') {
await checkExitApp(context);
} else {
await navigateBack(context, fileManager);
}
}
/// 返回上一级
Future<void> navigateBack(
BuildContext context,
FileManagerProvider fileManager,
) async {
await fileManager.goBack();
}
/// 检查退出应用
Future<void> checkExitApp(BuildContext context) async {
final now = DateTime.now();
if (_lastSwipeTime != null && now.difference(_lastSwipeTime!).inSeconds < 2) {
_exitHintOverlay.remove();
if (DesktopService.isDesktopPlatform) {
await windowManager.hide();
} else {
SystemNavigator.pop();
}
} else {
_lastSwipeTime = now;
_exitHintOverlay.show(context);
Future.delayed(const Duration(seconds: 2), () {
_exitHintOverlay.remove();
});
}
}
}
@@ -0,0 +1,46 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class GlassmorphismContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
final double sigmaX;
final double sigmaY;
final EdgeInsetsGeometry? padding;
const GlassmorphismContainer({
super.key,
required this.child,
this.borderRadius = 12,
this.sigmaX = 10,
this.sigmaY = 10,
this.padding,
});
@override
Widget build(BuildContext context) {
final isLight = Theme.of(context).brightness == Brightness.light;
final bgColor = isLight
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.3);
final borderColor = isLight
? Colors.white.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.1);
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigmaX, sigmaY: sigmaY),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: borderColor),
),
child: child,
),
),
);
}
}
+445
View File
@@ -0,0 +1,445 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart';
import '../../data/models/file_model.dart';
import '../../services/file_service.dart';
import '../../services/storage_service.dart';
import '../providers/file_manager_provider.dart';
import '../providers/navigation_provider.dart';
import 'glassmorphism_container.dart';
/// 搜索对话框
class SearchDialog extends StatefulWidget {
const SearchDialog({super.key});
static Future<void> show(BuildContext context) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '搜索',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(),
);
}
@override
State<SearchDialog> createState() => _SearchDialogState();
}
class _SearchDialogState extends State<SearchDialog> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
List<FileModel> _files = [];
List<String> _searchHistory = [];
bool _isLoading = false;
String? _errorMessage;
bool _caseFolding = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadHistory();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _searchFocusNode.requestFocus();
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadHistory() async {
final history = await StorageService.instance.getSearchHistory();
if (mounted) setState(() => _searchHistory = history);
}
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
if (value.trim().isEmpty) {
setState(() {
_files = [];
_isLoading = false;
_errorMessage = null;
});
return;
}
setState(() => _isLoading = true);
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_performSearch(value.trim());
});
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().searchFiles(
name: query,
caseFolding: _caseFolding,
);
final filesData = response['files'] as List<dynamic>? ?? [];
final files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
if (!mounted) return;
setState(() {
_files = files;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
void _toggleCaseFolding() {
setState(() => _caseFolding = !_caseFolding);
final query = _searchController.text.trim();
if (query.isNotEmpty) _performSearch(query);
}
Future<void> _clearHistory() async {
await StorageService.instance.clearSearchHistory();
if (mounted) setState(() => _searchHistory = []);
}
Future<void> _onResultTap(FileModel file) async {
final query = _searchController.text.trim();
// 在 async gap 之前获取所有引用
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager =
Provider.of<FileManagerProvider>(context, listen: false);
final parentPath = _extractParentPath(file.path);
final filePath = file.path;
if (query.isNotEmpty) {
await StorageService.instance.addToSearchHistory(query);
}
if (parentPath == null || !mounted) return;
Navigator.of(context).pop();
navProvider.setIndex(1);
fileManager.navigateAndHighlight(parentPath, filePath);
}
String? _extractParentPath(String filePath) {
const prefix = 'cloudreve://my';
if (!filePath.startsWith(prefix)) return null;
final relativePath = filePath.substring(prefix.length);
if (relativePath.isEmpty) return null;
final parts = relativePath.split('/');
if (parts.length > 1) parts.removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final isWide = screenWidth >= 600;
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
return Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSearchInput(context),
const Divider(height: 1),
Flexible(child: _buildBody(context)),
],
),
),
),
),
),
);
}
Widget _buildSearchInput(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.search, size: 20, color: theme.hintColor),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: '搜索文件...',
border: InputBorder.none,
hintStyle: TextStyle(color: theme.hintColor),
),
style: theme.textTheme.bodyLarge,
textInputAction: TextInputAction.search,
onChanged: _onSearchChanged,
onSubmitted: (value) {
_debounceTimer?.cancel();
_performSearch(value.trim());
},
),
),
IconButton(
icon: Icon(
LucideIcons.caseSensitive,
size: 18,
color: _caseFolding ? colorScheme.primary : theme.hintColor,
),
onPressed: _toggleCaseFolding,
tooltip: '忽略大小写',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(LucideIcons.x, size: 18, color: theme.hintColor),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
Widget _buildBody(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (_isLoading && _files.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (_errorMessage != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.alertCircle,
size: 40, color: colorScheme.error.withValues(alpha: 0.7)),
const SizedBox(height: 8),
Text(_errorMessage!,
style: TextStyle(color: theme.hintColor),
textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () =>
_performSearch(_searchController.text.trim()),
child: const Text('重试'),
),
],
),
),
);
}
if (_files.isEmpty) {
final query = _searchController.text.trim();
if (query.isEmpty && _searchHistory.isNotEmpty) {
return _buildHistorySection(context);
}
if (query.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.searchX,
size: 40,
color: theme.hintColor.withValues(alpha: 0.5)),
const SizedBox(height: 8),
Text('未找到匹配文件',
style: TextStyle(color: theme.hintColor)),
],
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text('输入关键词搜索文件',
style: TextStyle(color: theme.hintColor)),
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
controller: _scrollController,
shrinkWrap: true,
itemCount: _files.length,
separatorBuilder: (context, index) =>
Divider(height: 1, indent: 68, endIndent: 20),
itemBuilder: (context, index) =>
_buildResultItem(context, _files[index]),
),
);
}
Widget _buildHistorySection(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(LucideIcons.history, size: 16, color: theme.hintColor),
const SizedBox(width: 6),
Text('搜索历史',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.hintColor)),
const Spacer(),
GestureDetector(
onTap: _clearHistory,
child: Text('清除',
style:
TextStyle(fontSize: 12, color: colorScheme.primary)),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _searchHistory.map((query) {
return ActionChip(
label: Text(query, style: const TextStyle(fontSize: 13)),
onPressed: () {
_searchController.text = query;
_onSearchChanged(query);
},
avatar: Icon(LucideIcons.search, size: 14),
);
}).toList(),
),
],
),
);
}
Widget _buildResultItem(BuildContext context, FileModel file) {
final theme = Theme.of(context);
return InkWell(
onTap: () => _onResultTap(file),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
FileIconUtils.buildIconWidget(context: context, file: file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
Uri.decodeComponent(file.relativePath),
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Text(
file.isFolder
? '文件夹'
: date_utils.DateUtils.formatFileSize(file.size),
style: TextStyle(fontSize: 12, color: theme.hintColor),
),
],
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// 选择工具栏组件
class SelectionToolbar extends StatelessWidget {
final int selectionCount;
final VoidCallback onCancel;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback onDelete;
const SelectionToolbar({
super.key,
required this.selectionCount,
required this.onCancel,
this.onRename,
this.onMove,
this.onCopy,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Text(
'已选择 $selectionCount',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.cancel),
onPressed: onCancel,
tooltip: '取消选择',
),
if (selectionCount == 1 && onRename != null)
IconButton(
icon: const Icon(Icons.edit),
onPressed: onRename,
tooltip: '重命名',
),
if (onMove != null)
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
onPressed: onMove,
tooltip: '移动',
),
if (onCopy != null)
IconButton(
icon: const Icon(Icons.content_copy),
onPressed: onCopy,
tooltip: '复制',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: onDelete,
tooltip: '删除',
color: Colors.red,
),
],
),
);
}
}
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import '../../data/models/share_model.dart';
/// 截断文件名,智能截断避免多个小数点
String _truncateFileName(String name, int maxLength) {
if (name.length <= maxLength) {
return name;
}
// 查找最后一个点
final lastDotIndex = name.lastIndexOf('.');
// 如果没有点,或者点在开头或末尾,直接截断
if (lastDotIndex <= 0 || lastDotIndex >= name.length - 1) {
return '${name.substring(0, maxLength - 3)}...';
}
// 有扩展名的情况
final extension = name.substring(lastDotIndex);
final nameWithoutExt = name.substring(0, lastDotIndex);
// 扩展名太长,直接截断整个文件名
if (extension.length >= maxLength) {
return '${name.substring(0, maxLength - 3)}...';
}
// 计算主体部分能显示的长度
const ellipsis = '...';
final maxNameLength = maxLength - extension.length - ellipsis.length;
// 空间太小,主体部分至少保留3个字符
if (maxNameLength < 3) {
return '${name.substring(0, maxLength - 3)}...';
}
// 主体部分能完整显示
if (nameWithoutExt.length <= maxNameLength) {
return '$nameWithoutExt$ellipsis$extension';
}
// 主体部分需要截断
return '${nameWithoutExt.substring(0, maxNameLength)}$ellipsis$extension';
}
/// 文件名显示组件
class _FileNameDisplay extends StatelessWidget {
final String name;
final Color? textColor;
const _FileNameDisplay({
required this.name,
this.textColor,
});
/// 精确计算文本宽度
String _fitText(String text, double maxWidth, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (textPainter.width <= maxWidth) {
return text;
}
// 二分查找最佳长度
int min = 1;
int max = text.length;
int best = 0;
while (min <= max) {
final mid = ((min + max) / 2).floor();
final candidate = _truncateFileName(text, mid);
final painter = TextPainter(
text: TextSpan(text: candidate, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
painter.layout();
if (painter.width <= maxWidth) {
best = mid;
min = mid + 1;
} else {
max = mid - 1;
}
}
return _truncateFileName(text, best);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final style = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: textColor,
);
final fittedName = _fitText(name, maxWidth, style);
return Text(
fittedName,
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
}
/// 分享列表项
class ShareListItem extends StatelessWidget {
final ShareModel share;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const ShareListItem({
super.key,
required this.share,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final expired = share.expired;
final textColor = expired ? Colors.grey.shade600 : null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onEdit,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
flex: 65,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
share.isFolder
? Icons.folder
: Icons.insert_drive_file_outlined,
color: textColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: _FileNameDisplay(
name: share.name,
textColor: textColor,
),
),
],
),
const SizedBox(height: 4),
Text(
share.url,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Expanded(
flex: 35,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (share.isPrivate ?? false) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock, size: 14, color: Colors.blue),
],
),
),
],
if (share.downloaded != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${share.downloaded}',
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
),
Icon(Icons.download, size: 14, color: Colors.green.shade700),
],
),
),
],
...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${share.visited}',
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
),
Icon(Icons.visibility, size: 14, color: Colors.yellow.shade700),
],
),
),
],
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onEdit != null) ...[
IconButton(
icon: const Icon(Icons.edit, size: 24),
onPressed: onEdit,
tooltip: '编辑',
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
if (onDelete != null) ...[
IconButton(
icon: const Icon(Icons.delete, size: 24),
onPressed: onDelete,
tooltip: '删除',
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
style: IconButton.styleFrom(
foregroundColor: Colors.red.shade700,
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,92 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../data/models/file_model.dart';
import '../../services/cache_manager_service.dart';
import '../../services/thumbnail_service.dart';
import '../../core/utils/file_icon_utils.dart';
/// 缩略图加载组件 — 异步获取 URL 并用 CachedNetworkImage 渲染
class ThumbnailImage extends StatefulWidget {
final FileModel file;
final String? contextHint;
final double borderRadius;
const ThumbnailImage({
super.key,
required this.file,
this.contextHint,
this.borderRadius = 10,
});
@override
State<ThumbnailImage> createState() => _ThumbnailImageState();
}
class _ThumbnailImageState extends State<ThumbnailImage> {
String? _imageUrl;
bool _isLoading = true;
bool _hasError = false;
@override
void initState() {
super.initState();
_loadThumbnail();
}
@override
void didUpdateWidget(ThumbnailImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.file.path != widget.file.path) {
_loadThumbnail();
}
}
Future<void> _loadThumbnail() async {
setState(() {
_isLoading = true;
_hasError = false;
});
final url = await ThumbnailService.instance.getThumbnailUrl(
fileUri: widget.file.relativePath,
contextHint: widget.contextHint,
);
if (!mounted) return;
setState(() {
_imageUrl = url;
_isLoading = false;
_hasError = url == null;
});
}
Widget _buildPlaceholder(BuildContext context) {
return Center(
child: FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 40,
iconSize: 22,
borderRadius: widget.borderRadius,
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) return _buildPlaceholder(context);
if (_hasError || _imageUrl == null) return _buildPlaceholder(context);
return ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: CachedNetworkImage(
imageUrl: _imageUrl!,
cacheManager: CacheManagerService.instance.manager,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(context),
errorWidget: (context, url, error) => _buildPlaceholder(context),
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:oktoast/oktoast.dart';
/// Toast 封装组件
class ToastHelper {
ToastHelper._();
/// 显示成功提示
static void success(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFE8F5E9),
textColor: const Color(0xFF2E7D32),
);
}
/// 显示失败提示
static void failure(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFEBEE),
textColor: const Color(0xFFC62828),
);
}
/// 显示错误提示
static void error(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFF3E0),
textColor: const Color(0xFFEF6C00),
duration: const Duration(seconds: 3),
);
}
/// 显示警告提示
static void warning(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFF8E1),
textColor: const Color(0xFFFF8F00),
);
}
/// 显示信息提示
static void info(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFE3F2FD),
textColor: const Color(0xFF1565C0),
);
}
static void _showToast(
String message, {
required Color backgroundColor,
required Color textColor,
Duration duration = const Duration(seconds: 2),
}) {
showToastWidget(
_buildToast(message: message, backgroundColor: backgroundColor, textColor: textColor),
duration: duration,
position: ToastPosition.bottom,
);
}
static Widget _buildToast({
required String message,
required Color backgroundColor,
required Color textColor,
}) {
return Builder(builder: (context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: textColor.withValues(alpha: 0.15)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(
message,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: theme.textTheme.bodyMedium?.fontFamily,
),
),
);
});
}
/// 显示自定义图标提示
static void showWithIcon({
required String message,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color textColor,
Duration duration = const Duration(seconds: 2),
}) {
showToastWidget(
_buildIconToast(
message: message,
icon: icon,
iconColor: iconColor,
backgroundColor: backgroundColor,
textColor: textColor,
),
duration: duration,
position: ToastPosition.bottom,
);
}
static Widget _buildIconToast({
required String message,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color textColor,
}) {
return Builder(builder: (context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: iconColor.withValues(alpha: 0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 10),
Flexible(
child: Text(
message,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: theme.textTheme.bodyMedium?.fontFamily,
),
),
),
],
),
);
});
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/upload_manager_provider.dart';
import '../providers/file_manager_provider.dart';
import '../providers/navigation_provider.dart';
import 'glassmorphism_container.dart';
import 'toast_helper.dart';
/// 显示上传对话框(毛玻璃风格)
void showUploadDialog(BuildContext context) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '上传',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) =>
const _UploadDialogContent(),
);
}
class _UploadDialogContent extends StatelessWidget {
const _UploadDialogContent();
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 420.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: _buildFileSelectionButtons(context),
),
const Divider(height: 1),
_buildViewTasksButton(context),
],
),
),
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.uploadCloud, size: 20, color: theme.hintColor),
const SizedBox(width: 10),
Text(
'选择要上传的文件',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
Widget _buildFileSelectionButtons(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildUploadOption(
context,
icon: LucideIcons.image,
label: '选择图片',
description: 'JPG, PNG, GIF, WebP 等',
color: Colors.purple.shade400,
onTap: () => _pickFiles(context, FileType.image),
),
const SizedBox(height: 12),
_buildUploadOption(
context,
icon: LucideIcons.video,
label: '选择视频',
description: 'MP4, AVI, MKV 等',
color: Colors.orange.shade400,
onTap: () => _pickFiles(context, FileType.video),
),
const SizedBox(height: 12),
_buildUploadOption(
context,
icon: LucideIcons.file,
label: '选择所有文件',
description: '任意类型文件',
color: colorScheme.primary,
onTap: () => _pickFiles(context, FileType.any),
),
],
);
}
Widget _buildUploadOption(
BuildContext context, {
required IconData icon,
required String label,
required String description,
required Color color,
required VoidCallback onTap,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 20, color: color),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
),
),
Icon(LucideIcons.chevronRight, size: 18, color: Theme.of(context).hintColor),
],
),
),
);
}
Widget _buildViewTasksButton(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
Navigator.of(context).pop();
Provider.of<NavigationProvider>(context, listen: false).setIndex(2);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.list, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'查看上传任务',
style: TextStyle(
fontWeight: FontWeight.w500,
color: colorScheme.primary,
),
),
],
),
),
);
}
Future<void> _pickFiles(BuildContext context, FileType type) async {
try {
final result = await FilePicker.platform.pickFiles(
type: type,
allowMultiple: true,
);
if (context.mounted) {
Navigator.of(context).pop();
}
if (!context.mounted) return;
if (result == null || result.files.isEmpty) {
ToastHelper.warning('未选择文件');
return;
}
final files = <File>[];
for (final file in result.files) {
if (file.path != null) {
files.add(File(file.path!));
}
}
if (files.isEmpty) {
if (!context.mounted) return;
ToastHelper.error('无法获取文件路径');
return;
}
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog();
await uploadManager.startUpload(files, fileManager.currentPath);
if (context.mounted) {
ToastHelper.info('上传已开始,查看任务页');
}
} catch (e) {
if (!context.mounted) return;
ToastHelper.failure('选择文件失败: $e');
}
}
}
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/upload_task_model.dart';
import '../../services/upload_service.dart';
import '../providers/upload_manager_provider.dart';
import 'upload_progress_item.dart';
/// 上传进度对话框
class UploadProgressDialog extends StatelessWidget {
const UploadProgressDialog({super.key});
@override
Widget build(BuildContext context) {
return Consumer<UploadService>(
builder: (context, uploadService, child) {
final tasks = uploadService.allTasks;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300,
// 当没有任务时,最小高度约为2个任务的大小(约160px)
minHeight: tasks.isEmpty ? 160 : 0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题栏
_buildHeader(context, uploadService, tasks),
// 任务列表
Flexible(
child: tasks.isEmpty
? _buildEmptyState(context)
: _buildTaskList(context, uploadService, tasks),
),
// 底部操作栏
_buildFooter(context, uploadService, tasks),
],
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload,
size: 48,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'暂无上传任务',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
);
}
Widget _buildTaskList(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
return ListView.separated(
shrinkWrap: true,
itemCount: tasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = tasks[index];
return UploadProgressItem(
key: ValueKey(task.id),
task: task,
onPause: () => uploadService.cancelUpload(task.id),
onResume: () => uploadService.retryUpload(task.id),
onCancel: () => uploadService.cancelUpload(task.id),
onDelete: () async {
uploadService.removeTask(task.id);
},
onRetry: () => uploadService.retryUpload(task.id),
);
},
);
}
Widget _buildHeader(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
const Icon(Icons.cloud_upload, color: Colors.blue),
const SizedBox(width: 12),
const Text(
'上传任务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Consumer<UploadService>(
builder: (context, uploadService, child) {
final uploadingCount = uploadService.activeTasks.length;
if (uploadingCount > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'上传中: $uploadingCount',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
uploadManager.hideDialog();
Navigator.of(context).pop();
},
tooltip: '关闭',
),
],
),
);
}
Widget _buildFooter(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
final hasCompleted = tasks.any((t) => t.status == UploadStatus.completed);
final hasFailed = tasks.any((t) => t.status == UploadStatus.failed);
final hasCancelled = tasks.any((t) => t.status == UploadStatus.cancelled);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
if (hasCompleted || hasCancelled)
TextButton.icon(
icon: const Icon(Icons.delete_sweep, size: 18),
label: const Text('清除已完成'),
onPressed: () async {
uploadService.clearCompletedTasks();
},
),
if (hasFailed)
TextButton.icon(
icon: const Icon(Icons.clear_all, size: 18),
label: const Text('清除失败'),
onPressed: () {
uploadService.clearFailedTasks();
},
),
const Spacer(),
FilledButton(
onPressed: () {
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
uploadManager.hideDialog();
Navigator.of(context).pop();
},
child: const Text('关闭'),
),
],
),
);
}
}
/// 显示上传对话框
void showUploadDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const UploadProgressDialog(),
barrierDismissible: false,
);
}
@@ -0,0 +1,336 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/upload_task_model.dart';
/// 上传任务列表项
class UploadProgressItem extends StatelessWidget {
final UploadTaskModel task;
final VoidCallback? onPause;
final VoidCallback? onResume;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
final VoidCallback? onRetry;
final VoidCallback? onNavigate;
const UploadProgressItem({
super.key,
required this.task,
this.onPause,
this.onResume,
this.onCancel,
this.onDelete,
this.onRetry,
this.onNavigate,
});
@override
Widget build(BuildContext context) {
final isUploading = task.status == UploadStatus.uploading;
final isWaiting = task.status == UploadStatus.waiting;
final isPaused = task.status == UploadStatus.paused;
final isFailed = task.status == UploadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: _getCardColor(context, task.status),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getBorderColor(context, task.status),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildStatusIcon(context, task.status),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
_buildStatusRow(context, task),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: _buildActionButtons(context, task),
),
],
),
if (isUploading || isWaiting || isPaused) ...[
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: isPaused ? null : task.progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
isPaused ? '已暂停' : task.progressText,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${task.uploadedBytes}/${task.fileSize} bytes',
style: const TextStyle(fontSize: 12),
),
const Spacer(),
if (isUploading && task.speedText.isNotEmpty) ...[
Text(
task.speedText,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
],
Text(
task.readableFileSize,
style: const TextStyle(fontSize: 12),
),
],
),
] else if (isFailed && task.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
task.errorMessage!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
);
}
Widget _buildStatusIcon(BuildContext context, UploadStatus status) {
final color = _getStatusColor(status);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getStatusIcon(status),
size: 18,
color: color,
),
);
}
Widget _buildStatusRow(BuildContext context, UploadTaskModel task) {
final color = _getStatusColor(task.status);
final isCompleted = task.status == UploadStatus.completed;
return Row(
children: [
Text(
task.statusText,
style: TextStyle(fontSize: 12, color: color),
),
if (isCompleted) ...[
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
task.readableFileSize,
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
_formatDateTime(task.completedAt!),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
],
);
}
List<Widget> _buildActionButtons(
BuildContext context,
UploadTaskModel task,
) {
final errorColor = Theme.of(context).colorScheme.error;
switch (task.status) {
case UploadStatus.waiting:
case UploadStatus.uploading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: onPause,
tooltip: '暂停',
),
];
case UploadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: onResume,
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
case UploadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: onRetry,
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case UploadStatus.completed:
return [
if (onNavigate != null)
IconButton(
icon: const Icon(LucideIcons.folderOpen, size: 20),
onPressed: onNavigate,
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case UploadStatus.cancelled:
return [
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
}
}
IconData _getStatusIcon(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return LucideIcons.clock;
case UploadStatus.uploading:
return LucideIcons.upload;
case UploadStatus.completed:
return LucideIcons.checkCircle2;
case UploadStatus.paused:
return LucideIcons.pause;
case UploadStatus.failed:
case UploadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return Colors.orange;
case UploadStatus.uploading:
return Colors.blue;
case UploadStatus.completed:
return Colors.green;
case UploadStatus.paused:
return Colors.orange;
case UploadStatus.failed:
case UploadStatus.cancelled:
return Colors.red;
}
}
Color _getCardColor(BuildContext context, UploadStatus status) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case UploadStatus.completed:
return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05);
case UploadStatus.failed:
case UploadStatus.cancelled:
return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05);
default:
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
}
}
Color _getBorderColor(BuildContext context, UploadStatus status) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case UploadStatus.completed:
return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15);
case UploadStatus.failed:
case UploadStatus.cancelled:
return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15);
default:
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
}
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/services/avatar_cache_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// 用户头像组件
/// 1. 缓存存在 → 直接 FileImage 显示
/// 2. 缓存不存在 → 默认显示首字母 fallback,异步 getAvatar,成功后更新 UI
class UserAvatar extends StatefulWidget {
final String userId;
final String? email;
final String displayName;
final double radius;
final String? avatarType;
const UserAvatar({
super.key,
required this.userId,
this.email,
required this.displayName,
this.radius = 24,
this.avatarType,
});
@override
State<UserAvatar> createState() => _UserAvatarState();
/// 清除指定用户的头像缓存(头像上传/切换后调用)
static Future<void> evictCache(String userId) async {
await AvatarCacheService.instance.evictCache(userId);
}
/// 清除所有头像缓存(登出时调用)
static Future<void> clearAllCache() async {
await AvatarCacheService.instance.clearAllCache();
}
}
class _UserAvatarState extends State<UserAvatar> {
File? _avatarFile;
bool _loadTriggered = false;
String get _initial {
final name = widget.displayName;
return name.isNotEmpty ? name[0].toUpperCase() : 'U';
}
@override
void initState() {
super.initState();
_checkCache();
AvatarCacheService.instance.addListener(_onServiceNotify);
}
@override
void didUpdateWidget(UserAvatar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId || oldWidget.email != widget.email) {
_avatarFile = null;
_loadTriggered = false;
_checkCache();
}
}
@override
void dispose() {
AvatarCacheService.instance.removeListener(_onServiceNotify);
super.dispose();
}
void _checkCache() {
if (widget.userId.isEmpty) return;
final file = AvatarCacheService.instance.getCachedFile(widget.userId);
if (file != null) {
_avatarFile = file;
}
}
void _onServiceNotify() {
final file = AvatarCacheService.instance.getCachedFile(widget.userId);
if (file != null && _avatarFile?.path != file.path) {
setState(() => _avatarFile = file);
}
}
Future<void> _loadIfMissing() async {
if (_loadTriggered || widget.userId.isEmpty) return;
_loadTriggered = true;
final auth = context.read<AuthProvider>();
await AvatarCacheService.instance.getAvatar(
widget.userId,
baseUrl: auth.currentServer?.baseUrl,
token: auth.token?.accessToken,
email: widget.email,
);
// getAvatar 成功后 notifyListeners → _onServiceNotify 更新 _avatarFile
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// 有缓存文件,显示图片
if (_avatarFile != null) {
return CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.primaryContainer,
backgroundImage: FileImage(_avatarFile!),
);
}
// 缓存不存在,触发异步加载
_loadIfMissing();
// Fallback: 首字母(默认展示,避免空白)
return CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.primaryContainer,
child: Text(
_initial,
style: TextStyle(
fontSize: widget.radius * 0.75,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More