二次开发源码提交

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
+2
View File
@@ -0,0 +1,2 @@
# 所有文本文件统一使用 LF
* text=auto eol=lf
+85
View File
@@ -0,0 +1,85 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
.frontend
.frontend/
# custom
CLAUDE.md
claude_project.md
PROJECT_REQUIREMENTS.md
DESIGN_OVERVIEW.md
DESIGN.md
refactory.md
DESIGN-copy.md
*.jsonl
example.dart
install.sh
dist/
# Local secrets/config that should not be published.
.env
.env.*
!.env.example
*.pem
*.key
*.crt
*.cer
*.der
*.p12
*.pfx
*.keystore
*.jks
key.properties
android/key.properties
android/app/key.properties
**/google-services.json
**/GoogleService-Info.plist
lib/firebase_options.dart
*credential*.json
*credentials*.json
*secret*.json
*secrets*.json
*.local.json
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+39
View File
@@ -0,0 +1,39 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: linux
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: web
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: windows
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+106
View File
@@ -0,0 +1,106 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
import java.util.Properties
import java.io.FileInputStream
// 1. Create a Properties object
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
// 2. Load the file if it exists
if (localPropertiesFile.exists()) {
localProperties.load(FileInputStream(localPropertiesFile))
}
// 3. Helper function to read the value as an Int
fun getLocalProperty(key: String, defaultValue: Int): Int {
val value = localProperties.getProperty(key)
return value?.toIntOrNull() ?: defaultValue
}
// 1. 加载 key.properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.limo.cloudreve4_flutter"
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
ndkVersion = flutter.ndkVersion
// 2. 配置签名选项
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.limo.cloudreve4_flutter"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = getLocalProperty("flutter.minSdkVersion", 24)
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
versionCode = flutter.versionCode
versionName = flutter.versionName
}
packaging {
resources {
// 如果遇到重复的 .so 文件,优先取第一个
pickFirst("lib/**/libmpv.so")
pickFirst("lib/**/libmediakitandroidhelper.so")
}
}
buildTypes {
release {
// 1. 将原来的 getByName("debug") 替换为 getByName("release")
signingConfig = signingConfigs.getByName("release")
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
applicationVariants.all {
val variant = this
outputs.all {
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
val appName = "cloudreve4_flutter"
val versionName = variant.versionName
val versionCode = variant.versionCode
val type = variant.name // release 或 debug
val abi = output.getFilter(com.android.build.OutputFile.ABI) ?: "universal"
output.outputFileName = "${appName}_v${versionName}_${versionCode}_${abi}_release.apk"
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+84
View File
@@ -0,0 +1,84 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Release 版本需要显示添加网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 如果是 Android 13+ 处理媒体文件,通常还需要以下权限(可选) -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- 读写权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- 后台下载权限 -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 基础前台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- ✅ Android 14+ 必须添加这两行,否则无法开启 dataDownload / dataSync 类型服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_DOWNLOAD" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- ✅ 如果你还需要在后台弹出通知显示进度,必须添加此权限 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 打开apk文件 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Android 9 (API 28) 强制HTTPS, 兼容需要添加 usesCleartextTraffic -->
<application
android:label="公云存储"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true" >
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- For browser links (https/http) -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
</intent>
<!-- If using Custom Tabs/inAppBrowserView -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.limo.cloudreve4_flutter
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+3
View File
@@ -0,0 +1,3 @@
# 用于 easy_localization 生成翻译文件的配置
output_dir: lib/core/i18n
+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')}';
}
}
}

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