diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d4083aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# 所有文本文件统一使用 LF +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4593f89 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6b44107 --- /dev/null +++ b/.metadata @@ -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' diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..7c15f2d --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4dfa863 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/cloudreve4_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/cloudreve4_flutter/MainActivity.kt new file mode 100644 index 0000000..a282c06 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/cloudreve4_flutter/MainActivity.kt @@ -0,0 +1,5 @@ +package com.limo.cloudreve4_flutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..4fe3d15 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..10bdbcf Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..3075dc4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..735734b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..473776d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/assets/fonts/NotoSansSC-Bold.ttf b/assets/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 0000000..17380a9 Binary files /dev/null and b/assets/fonts/NotoSansSC-Bold.ttf differ diff --git a/assets/fonts/NotoSansSC-Medium.ttf b/assets/fonts/NotoSansSC-Medium.ttf new file mode 100644 index 0000000..901afd4 Binary files /dev/null and b/assets/fonts/NotoSansSC-Medium.ttf differ diff --git a/assets/fonts/SourceCodePro-Regular.ttf b/assets/fonts/SourceCodePro-Regular.ttf new file mode 100644 index 0000000..fd02539 Binary files /dev/null and b/assets/fonts/SourceCodePro-Regular.ttf differ diff --git a/assets/icons/tray_icon.ico b/assets/icons/tray_icon.ico new file mode 100644 index 0000000..4185bb6 Binary files /dev/null and b/assets/icons/tray_icon.ico differ diff --git a/assets/icons/tray_icon.png b/assets/icons/tray_icon.png new file mode 100644 index 0000000..8d0b5e4 Binary files /dev/null and b/assets/icons/tray_icon.png differ diff --git a/assets/images/app_logo.png b/assets/images/app_logo.png new file mode 100644 index 0000000..c317978 Binary files /dev/null and b/assets/images/app_logo.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/flutter_gen.yaml b/flutter_gen.yaml new file mode 100644 index 0000000..c2218db --- /dev/null +++ b/flutter_gen.yaml @@ -0,0 +1,3 @@ +# 用于 easy_localization 生成翻译文件的配置 + +output_dir: lib/core/i18n diff --git a/lib/config/api_config.dart b/lib/config/api_config.dart new file mode 100644 index 0000000..712cbf9 --- /dev/null +++ b/lib/config/api_config.dart @@ -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 get baseUrl async { + return defaultBaseUrl; + } +} diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..889a37e --- /dev/null +++ b/lib/config/app_config.dart @@ -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; +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..4108dfc --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -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; +} diff --git a/lib/core/constants/quick_access_defaults.dart b/lib/core/constants/quick_access_defaults.dart new file mode 100644 index 0000000..c486e1c --- /dev/null +++ b/lib/core/constants/quick_access_defaults.dart @@ -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 = [ + 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(0xFFF0ABFC), + Color(0xFFFCD34D), + Color(0xFF93C5FD), + Color(0xFF86EFAC), + Color(0xFFFCA5A5), + Color(0xFFFDBA74), + Color(0xFFC4B5FD), + Color(0xFF67E8F9), + ]; + + Map toJson() => { + 'id': id, + 'label': label, + 'iconCode': icon.codePoint, + 'path': path, + 'color': color.toARGB32(), + 'isDefault': isDefault, + }; + + factory QuickAccessConfig.fromJson(Map json) { + final iconCode = json['iconCode'] as int? ?? LucideIcons.folder.codePoint; + final matchedIcon = iconPool.cast().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 parseSaved(String saved) { + try { + final list = jsonDecode(saved) as List; + return list.map((e) => QuickAccessConfig.fromJson(e as Map)).toList(); + } catch (_) { + return List.from(defaults); + } + } + + static String serialize(List items) { + return jsonEncode(items.map((e) => e.toJson()).toList()); + } + + /// 迁移旧格式(分号分隔的路径列表) + static List migrateV1(String saved) { + final parts = saved.split(';'); + final items = []; + 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(); + } +} diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart new file mode 100644 index 0000000..8918ed3 --- /dev/null +++ b/lib/core/constants/storage_keys.dart @@ -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'; +} diff --git a/lib/core/exceptions/app_exception.dart b/lib/core/exceptions/app_exception.dart new file mode 100644 index 0000000..1149a7f --- /dev/null +++ b/lib/core/exceptions/app_exception.dart @@ -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); +} diff --git a/lib/core/utils/app_logger.dart b/lib/core/utils/app_logger.dart new file mode 100644 index 0000000..0c06827 --- /dev/null +++ b/lib/core/utils/app_logger.dart @@ -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 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 args) => _instance.d(message, error: args); + + /// Info 级别日志(支持格式化) + static void ifn(String message, List args) => _instance.i(message, error: args); + + /// Warning 级别日志(支持格式化) + static void wf(String message, List args) => _instance.w(message, error: args); + + /// Error 级别日志(支持格式化) + static void ef(String message, List args) => _instance.e(message, error: args); + + /// 获取日志文件路径 + static Future get logFilePath async { + if (_logFile != null) return _logFile!.path; + final appDir = await getApplicationSupportDirectory(); + return p.join(appDir.path, 'logs', 'log.txt'); + } + + /// 获取日志文件大小(字节) + static Future get logFileSize async { + final path = await logFilePath; + final file = File(path); + if (await file.exists()) { + return await file.length(); + } + return 0; + } + + /// 清空日志文件(内容清零,不删除文件) + static Future clearLog() async { + final path = await logFilePath; + final file = File(path); + if (await file.exists()) { + await file.writeAsString(''); + } + } + + /// 导出日志文件到指定目录 + static Future 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 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 args) => AppLogger.df(message, args); + static void ifn(String message, List args) => AppLogger.ifn(message, args); + static void wf(String message, List args) => AppLogger.wf(message, args); + static void ef(String message, List args) => AppLogger.ef(message, args); +} diff --git a/lib/core/utils/avatar_utils.dart b/lib/core/utils/avatar_utils.dart new file mode 100644 index 0000000..8d69ee8 --- /dev/null +++ b/lib/core/utils/avatar_utils.dart @@ -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 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 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 setGravatarMirrorEnabled(bool enabled) async { + await StorageService.instance + .setBool(StorageKeys.gravatarMirrorEnabled, enabled); + } + + /// 设置 Gravatar 镜像地址 + static Future setGravatarMirrorUrl(String? url) async { + await StorageService.instance + .setString(StorageKeys.gravatarMirrorUrl, url); + } +} diff --git a/lib/core/utils/date_utils.dart b/lib/core/utils/date_utils.dart new file mode 100644 index 0000000..db47d88 --- /dev/null +++ b/lib/core/utils/date_utils.dart @@ -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; + } +} diff --git a/lib/core/utils/file_icon_utils.dart b/lib/core/utils/file_icon_utils.dart new file mode 100644 index 0000000..f09878d --- /dev/null +++ b/lib/core/utils/file_icon_utils.dart @@ -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 = { + '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 = { + '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 = { + '文档': 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 = { + '文档': 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), + ); + } +} diff --git a/lib/core/utils/file_type_utils.dart b/lib/core/utils/file_type_utils.dart new file mode 100644 index 0000000..2f3adba --- /dev/null +++ b/lib/core/utils/file_type_utils.dart @@ -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 '文件'; + } +} diff --git a/lib/core/utils/file_utils.dart b/lib/core/utils/file_utils.dart new file mode 100644 index 0000000..d9a0280 --- /dev/null +++ b/lib/core/utils/file_utils.dart @@ -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'; + } +} diff --git a/lib/core/utils/language_preview.dart b/lib/core/utils/language_preview.dart new file mode 100644 index 0000000..70f5e8d --- /dev/null +++ b/lib/core/utils/language_preview.dart @@ -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', + }; +} \ No newline at end of file diff --git a/lib/core/utils/time_flow_decoder.dart b/lib/core/utils/time_flow_decoder.dart new file mode 100644 index 0000000..69f1adf --- /dev/null +++ b/lib/core/utils/time_flow_decoder.dart @@ -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 = []; + 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); + } +} diff --git a/lib/core/utils/video_fullscreen.dart b/lib/core/utils/video_fullscreen.dart new file mode 100644 index 0000000..66c77bc --- /dev/null +++ b/lib/core/utils/video_fullscreen.dart @@ -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(false); + +/// 进入视频全屏(系统级) +Future 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 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); + } +} diff --git a/lib/core/validators/string_validator.dart b/lib/core/validators/string_validator.dart new file mode 100644 index 0000000..0604326 --- /dev/null +++ b/lib/core/validators/string_validator.dart @@ -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; + } +} diff --git a/lib/data/models/admin_model.dart b/lib/data/models/admin_model.dart new file mode 100644 index 0000000..eb4cdb2 --- /dev/null +++ b/lib/data/models/admin_model.dart @@ -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 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? settings; + final int? maxStorage; + final int? storagePolicyId; + final DateTime? createdAt; + final DateTime? updatedAt; + final Map? 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 json) { + Map? storagePolicy; + if (json['edges'] is Map && json['edges']['storage_policies'] is Map) { + storagePolicy = json['edges']['storage_policies'] as Map; + } + + return AdminGroupModel( + id: json['id'] as int, + name: json['name'] as String, + permissions: json['permissions'] as String?, + settings: json['settings'] as Map?, + 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 json) { + AdminGroupModel? group; + if (json['edges'] is Map && json['edges']['group'] is Map) { + group = AdminGroupModel.fromJson( + json['edges']['group'] as Map); + } + + 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 groups; + final PaginationModel pagination; + + AdminGroupListResponse({required this.groups, required this.pagination}); + + factory AdminGroupListResponse.fromJson(Map json) { + return AdminGroupListResponse( + groups: (json['groups'] as List?) + ?.map((e) => AdminGroupModel.fromJson(e as Map)) + .toList() ?? + [], + pagination: PaginationModel.fromJson( + json['pagination'] as Map? ?? {}), + ); + } +} + +/// 管理员用户列表响应 +class AdminUserListResponse { + final List users; + final PaginationModel pagination; + + AdminUserListResponse({required this.users, required this.pagination}); + + factory AdminUserListResponse.fromJson(Map json) { + return AdminUserListResponse( + users: (json['users'] as List?) + ?.map((e) => AdminUserModel.fromJson(e as Map)) + .toList() ?? + [], + pagination: PaginationModel.fromJson( + json['pagination'] as Map? ?? {}), + ); + } +} diff --git a/lib/data/models/cache_settings_model.dart b/lib/data/models/cache_settings_model.dart new file mode 100644 index 0000000..71e82ec --- /dev/null +++ b/lib/data/models/cache_settings_model.dart @@ -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 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 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 get availableSizes => [100, 250, 500, 1000, 2000]; // MB + + /// 获取可用预设过期时间选项 + static List get availableDurations => [1, 3, 7, 15, 30]; // 天 +} diff --git a/lib/data/models/dav_account_model.dart b/lib/data/models/dav_account_model.dart new file mode 100644 index 0000000..7dd262e --- /dev/null +++ b/lib/data/models/dav_account_model.dart @@ -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 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 toJson() { + return { + 'id': id, + 'created_at': createdAt.toIso8601String(), + 'name': name, + 'uri': uri, + 'password': password, + 'options': options, + }; + } +} diff --git a/lib/data/models/download_task_model.dart b/lib/data/models/download_task_model.dart new file mode 100644 index 0000000..943e3b8 --- /dev/null +++ b/lib/data/models/download_task_model.dart @@ -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 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 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?, + ); + } +} diff --git a/lib/data/models/file_model.dart b/lib/data/models/file_model.dart new file mode 100644 index 0000000..147b3be --- /dev/null +++ b/lib/data/models/file_model.dart @@ -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? 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 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?, + permission: json['permission'] as String?, + primaryEntity: json['primary_entity'] as String?, + capability: json['capability'] as String?, + owned: json['owned'] as bool?, + ); + } + + Map 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? 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 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 toJson() { + return { + 'size': size, + 'files': files, + 'folders': folders, + 'completed': completed, + 'calculated_at': calculatedAt.toIso8601String(), + }; + } +} + +/// 扩展信息模型 +class ExtendedInfoModel { + final StoragePolicyModel? storagePolicy; + final int? storageUsed; + final List? shares; + final List? entities; + final List? directLinks; + + ExtendedInfoModel({ + this.storagePolicy, + this.storageUsed, + this.shares, + this.entities, + this.directLinks, + }); + + factory ExtendedInfoModel.fromJson(Map json) { + return ExtendedInfoModel( + storagePolicy: json['storage_policy'] is Map + ? StoragePolicyModel.fromJson(json['storage_policy'] as Map) + : null, + storageUsed: (json['storage_used'] as num?)?.toInt(), + shares: json['shares'] != null + ? (json['shares'] as List) + .map((e) => ShareModel.fromJson(e as Map)) + .toList() + : null, + entities: json['entities'] != null + ? (json['entities'] as List) + .map((e) => EntityModel.fromJson(e as Map)) + .toList() + : null, + directLinks: json['direct_links'] != null + ? (json['direct_links'] as List) + .map((e) => DirectLinkModel.fromJson(e as Map)) + .toList() + : null, + ); + } + + Map 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 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 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 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 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 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 + ? StoragePolicyModel.fromJson(json['storage_policy'] as Map) + : null, + createdBy: json['created_by'] is Map + ? EntityCreatedByModel.fromJson(json['created_by'] as Map) + : null, + ); + } + + Map 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 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 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 json) { + return FileInfoModel( + file: FileModel.fromJson(json), + folderSummary: json['folder_summary'] is Map + ? FolderSummaryModel.fromJson(json['folder_summary'] as Map) + : null, + extendedInfo: json['extended_info'] is Map + ? ExtendedInfoModel.fromJson(json['extended_info'] as Map) + : null, + ); + } +} diff --git a/lib/data/models/remote_download_task_model.dart b/lib/data/models/remote_download_task_model.dart new file mode 100644 index 0000000..45811e5 --- /dev/null +++ b/lib/data/models/remote_download_task_model.dart @@ -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 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 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 json) { + final filesList = json['files'] as List? ?? []; + 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)) + .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 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 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) + : 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 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) + : null, + summary: json['summary'] != null + ? TaskSummary.fromJson(json['summary'] as Map) + : null, + resumeTime: (json['resume_time'] as num?)?.toInt() ?? 0, + retryCount: (json['retry_count'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/data/models/server_model.dart b/lib/data/models/server_model.dart new file mode 100644 index 0000000..81a5099 --- /dev/null +++ b/lib/data/models/server_model.dart @@ -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 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) + : null, + ); + } + + Map toJson() { + return { + 'label': label, + 'baseUrl': baseUrl, + 'rememberMe': rememberMe, + 'email': email, + 'password': password, + 'user': user?.toJson(), + }; + } +} diff --git a/lib/data/models/share_model.dart b/lib/data/models/share_model.dart new file mode 100644 index 0000000..68ce97c --- /dev/null +++ b/lib/data/models/share_model.dart @@ -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 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 + ? ShareOwner.fromJson(json['owner'] as Map) + : 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 + ? SharePermissionSetting.fromJson(json['permission_setting'] as Map) + : 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 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 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 + ? ShareOwnerGroup.fromJson(json['group'] as Map) + : null, + shareLinksInProfile: json['share_links_in_profile'] as String?, + ); + } + + Map 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 json) { + return ShareOwnerGroup( + id: json['id'] as String, + name: json['name'] as String, + ); + } + + Map toJson() => {'id': id, 'name': name}; +} + +/// 权限设置 +class SharePermissionSetting { + final String? sameGroup; + final String? other; + final String? anonymous; + final String? everyone; + final Map? groupExplicit; + final Map? userExplicit; + + SharePermissionSetting({ + this.sameGroup, + this.other, + this.anonymous, + this.everyone, + this.groupExplicit, + this.userExplicit, + }); + + factory SharePermissionSetting.fromJson(Map 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?) + ?.cast(), + userExplicit: (json['user_explicit'] as Map?) + ?.cast(), + ); + } + + Map toJson() { + return { + 'same_group': sameGroup, + 'other': other, + 'anonymous': anonymous, + 'everyone': everyone, + 'group_explicit': groupExplicit, + 'user_explicit': userExplicit, + }; + } +} diff --git a/lib/data/models/upload_task_model.dart b/lib/data/models/upload_task_model.dart new file mode 100644 index 0000000..3a67586 --- /dev/null +++ b/lib/data/models/upload_task_model.dart @@ -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? 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 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), + 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 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 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 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, + ); + } +} diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart new file mode 100644 index 0000000..8e472d8 --- /dev/null +++ b/lib/data/models/user_model.dart @@ -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? 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 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) + : null, + pined: json['pined'] != null + ? (json['pined'] as List) + .map((e) => PinedFileModel.fromJson(e as Map)) + .toList() + : null, + token: json['token'] != null + ? TokenModel.fromJson(json['token'] as Map) + : null, + ); + } + + UserModel copyWith({ + String? id, + String? email, + String? nickname, + String? avatar, + DateTime? createdAt, + String? preferredTheme, + String? language, + bool? anonymous, + GroupModel? group, + List? 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 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 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 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 json) { + return PinedFileModel( + uri: json['uri'] as String, + name: json['name'] as String?, + ); + } + + Map 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 json) { + // 支持两种格式:直接是 token 数据或嵌套在 data 中 + Map data; + + if (json['data'] != null) { + data = json['data'] as Map; + } 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 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 json) { + return CapacityModel( + total: json['total'] as int, + used: json['used'] as int, + ); + } + + Map toJson() { + return {'total': total, 'used': used}; + } +} diff --git a/lib/data/models/user_setting_model.dart b/lib/data/models/user_setting_model.dart new file mode 100644 index 0000000..5b6d130 --- /dev/null +++ b/lib/data/models/user_setting_model.dart @@ -0,0 +1,346 @@ +/// 用户设置模型 - 对应 GET /user/setting 响应 +class UserSettingModel { + final DateTime? groupExpires; + final List openId; + final bool versionRetentionEnabled; + final List? versionRetentionExt; + final int versionRetentionMax; + final bool passwordless; + final bool twoFaEnabled; + final List passkeys; + final List loginActivity; + final List storagePacks; + final int credit; + final bool disableViewSync; + final String shareLinksInProfile; + final List 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 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)) + .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)) + .toList() ?? + [], + loginActivity: (json['login_activity'] as List?) + ?.map((e) => LoginActivity.fromJson(e as Map)) + .toList() ?? + [], + storagePacks: (json['storage_packs'] as List?) + ?.map((e) => StoragePack.fromJson(e as Map)) + .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)) + .toList() ?? + [], + ); + } + + UserSettingModel copyWith({ + DateTime? groupExpires, + List? openId, + bool? versionRetentionEnabled, + List? versionRetentionExt, + int? versionRetentionMax, + bool? passwordless, + bool? twoFaEnabled, + List? passkeys, + List? loginActivity, + List? storagePacks, + int? credit, + bool? disableViewSync, + String? shareLinksInProfile, + List? 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 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 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 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 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 scopes; + final DateTime? lastUsedAt; + + OAuthGrant({ + required this.clientId, + required this.clientName, + this.clientLogo, + this.scopes = const [], + this.lastUsedAt, + }); + + factory OAuthGrant.fromJson(Map 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 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 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 changes; + final String? nextToken; + + CreditChangeList({required this.changes, this.nextToken}); + + factory CreditChangeList.fromJson(Map json) { + return CreditChangeList( + changes: (json['changes'] as List?) + ?.map((e) => CreditChange.fromJson(e as Map)) + .toList() ?? + [], + nextToken: (json['pagination'] as Map?)?['next_token'] as String?, + ); + } + + bool get hasMore => nextToken != null && nextToken!.isNotEmpty; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..1b69301 --- /dev/null +++ b/lib/main.dart @@ -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 routeObserver = RouteObserver(); + +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 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(); + 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( + 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( + 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, + ); + } +} diff --git a/lib/presentation/pages/auth/forgot_password_page.dart b/lib/presentation/pages/auth/forgot_password_page.dart new file mode 100644 index 0000000..237ad15 --- /dev/null +++ b/lib/presentation/pages/auth/forgot_password_page.dart @@ -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 createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _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('发送重置邮件'), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/auth/login_page.dart b/lib/presentation/pages/auth/login_page.dart new file mode 100644 index 0000000..eec9d31 --- /dev/null +++ b/lib/presentation/pages/auth/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + 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 _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 _login() async { + if (!_formKey.currentState!.validate()) return; + + final navigator = Navigator.of(context); + final authProvider = Provider.of(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 _showTwoFactorDialog(String sessionId) async { + final success = await showDialog( + 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 _shakeAnimation; + + @override + void initState() { + super.initState(); + _shakeController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _shakeAnimation = TweenSequence([ + 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 _submit() async { + final code = _controller.text.trim(); + if (code.length != 6 || int.tryParse(code) == null) return; + + setState(() => _isSubmitting = true); + + final authProvider = Provider.of(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('验证'), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/auth/register_page.dart b/lib/presentation/pages/auth/register_page.dart new file mode 100644 index 0000000..47a1814 --- /dev/null +++ b/lib/presentation/pages/auth/register_page.dart @@ -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 createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _formKey = GlobalKey(); + 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 _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('已有账号?去登录'), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/files/files_page.dart b/lib/presentation/pages/files/files_page.dart new file mode 100644 index 0000000..a3126e7 --- /dev/null +++ b/lib/presentation/pages/files/files_page.dart @@ -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 createState() => _FilesPageState(); +} + +class _FilesPageState extends State { + bool _isFirstLoad = true; + FileModel? _infoFile; + final GlobalKey _scaffoldKey = GlobalKey(); + + // 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(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(context, listen: false); + downloadManager.initialize(); + } + }); + + // 上传完成 → 自动刷新当前目录文件列表 + UploadService.instance.onUploadCompleted = (targetPath, fileName) { + if (!mounted) return; + final fileManager = Provider.of(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( + 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 _buildDesktopActions() { + return [ + IconButton( + icon: const Icon(LucideIcons.search), + onPressed: () => SearchDialog.show(context), + tooltip: '搜索', + ), + Consumer( + builder: (context, fileManager, child) { + return IconButton( + icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh), + onPressed: () => fileManager.refreshFiles(), + tooltip: '刷新', + ); + }, + ), + Consumer( + 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(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(context, listen: false).setIndex(2), + tooltip: '下载', + ), + ]; + } + + List _buildMobileActions() { + return [ + Consumer( + 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(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( + 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 droppedFiles) { + final files = []; + for (final xFile in droppedFiles) { + final path = xFile.path; + if (path.isNotEmpty) { + files.add(File(path)); + } + } + if (files.isEmpty) return; + + final uploadManager = Provider.of(context, listen: false); + final fileManager = Provider.of(context, listen: false); + uploadManager.markShouldShowDialog(); + uploadManager.startUpload(files, fileManager.currentPath); + ToastHelper.info('已添加 ${files.length} 个文件到上传队列'); + } + + Widget _buildFileList(BuildContext context) { + return Consumer( + 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 _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 = []; + 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( + 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( + 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( + 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 _downloadFile( + BuildContext context, + FileManagerProvider fileManager, + FileModel file, + ) async { + final downloadManager = Provider.of(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 _openInBrowser(BuildContext context, FileModel file) async { + try { + final response = await FileService().getDownloadUrls( + uris: [file.relativePath], + download: true, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + 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'); + } + } + } +} diff --git a/lib/presentation/pages/overview/overview_page.dart b/lib/presentation/pages/overview/overview_page.dart new file mode 100644 index 0000000..275267d --- /dev/null +++ b/lib/presentation/pages/overview/overview_page.dart @@ -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 createState() => _OverviewPageState(); +} + +class _OverviewPageState extends State { + @override + void initState() { + super.initState(); + Future.microtask(() { + if (mounted) { + final userSetting = Provider.of( + context, listen: false); + userSetting.loadCapacity(); + + // 初始化/更新当前用户头像 + final auth = Provider.of(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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/overview/widgets/quick_access_grid.dart b/lib/presentation/pages/overview/widgets/quick_access_grid.dart new file mode 100644 index 0000000..e65ad59 --- /dev/null +++ b/lib/presentation/pages/overview/widgets/quick_access_grid.dart @@ -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 createState() => _QuickAccessGridState(); +} + +class _QuickAccessGridState extends State with RouteAware { + List _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 _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(context, listen: false); + final fileManager = Provider.of(context, listen: false); + navProvider.setIndex(1); + fileManager.enterFolder(path); + } + + Future _editShortcut(int index) async { + final item = _items[index]; + final labelController = TextEditingController(text: item.label); + final pathController = TextEditingController(text: item.path); + + final result = await showDialog( + 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 _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 _buildRows() { + final total = _items.length; + if (total == 0) return []; + + final maxCols = total > 6 ? 3 : 2; + const gap = 10.0; + final rows = []; + + for (int i = 0; i < total; i += maxCols) { + final rowItems = []; + 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 _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), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/overview/widgets/recent_activity_list.dart b/lib/presentation/pages/overview/widgets/recent_activity_list.dart new file mode 100644 index 0000000..29c4f5f --- /dev/null +++ b/lib/presentation/pages/overview/widgets/recent_activity_list.dart @@ -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( + 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 uploads, + List 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(context, listen: false); + final fileManager = Provider.of(context, listen: false); + + // 构造高亮路径:还原为 cloudreve:// 格式 + final highlightPath = path.startsWith('cloudreve://') + ? path + : 'cloudreve://my$path'; + + fileManager.navigateAndHighlight(parentPath, highlightPath); + navProvider.setIndex(1); + } + + Future _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, + }); +} diff --git a/lib/presentation/pages/overview/widgets/search_entry_card.dart b/lib/presentation/pages/overview/widgets/search_entry_card.dart new file mode 100644 index 0000000..b6bf736 --- /dev/null +++ b/lib/presentation/pages/overview/widgets/search_entry_card.dart @@ -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)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/overview/widgets/storage_usage_card.dart b/lib/presentation/pages/overview/widgets/storage_usage_card.dart new file mode 100644 index 0000000..886b468 --- /dev/null +++ b/lib/presentation/pages/overview/widgets/storage_usage_card.dart @@ -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( + 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; + } +} diff --git a/lib/presentation/pages/preview/audio_preview_page.dart b/lib/presentation/pages/preview/audio_preview_page.dart new file mode 100644 index 0000000..277bc08 --- /dev/null +++ b/lib/presentation/pages/preview/audio_preview_page.dart @@ -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 createState() => _AudioPreviewPageState(); +} + +class _AudioPreviewPageState extends State { + late final Player player; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + player = Player(); + _loadAudioUrl(); + } + + Future _loadAudioUrl() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: false, + entity: widget.entityId, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + 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 createState() => _AudioPlayerWidgetState(); +} + +class _AudioPlayerWidgetState extends State { + + @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')}'; + } +} diff --git a/lib/presentation/pages/preview/document_preview_page.dart b/lib/presentation/pages/preview/document_preview_page.dart new file mode 100644 index 0000000..73c56c2 --- /dev/null +++ b/lib/presentation/pages/preview/document_preview_page.dart @@ -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 createState() => _DocumentPreviewPageState(); +} + +class _DocumentPreviewPageState extends State { + 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 _loadFileContent() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: true, + entity: widget.entityId, + ); + final urls = response['urls'] as List? ?? []; + if (urls.isEmpty) throw Exception('获取URL为空'); + final urlData = urls[0] as Map; + 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, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/pages/preview/image_preview_page.dart b/lib/presentation/pages/preview/image_preview_page.dart new file mode 100644 index 0000000..afa67c0 --- /dev/null +++ b/lib/presentation/pages/preview/image_preview_page.dart @@ -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 createState() => _ImagePreviewPageState(); +} + +class _ImagePreviewPageState extends State { + String? _imageUrl; + bool _isLoading = true; + String? _errorMessage; + // 定义一个变量,防止多个动画冲突 + bool _isAnimating = false; + + @override + void initState() { + super.initState(); + _loadImageUrl(); + } + + Future _loadImageUrl() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: false, + entity: widget.entityId, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + 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( + 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; + }); + } +} diff --git a/lib/presentation/pages/preview/markdown_preview_page.dart b/lib/presentation/pages/preview/markdown_preview_page.dart new file mode 100644 index 0000000..7b0c474 --- /dev/null +++ b/lib/presentation/pages/preview/markdown_preview_page.dart @@ -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 createState() => _MarkdownPreviewPageState(); +} + +class _MarkdownPreviewPageState extends State { + 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 _loadFileContent() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: true, + entity: widget.entityId, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isEmpty) throw Exception('获取URL为空'); + + final urlData = urls[0] as Map; + 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, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/pages/preview/pdf_preview_page.dart b/lib/presentation/pages/preview/pdf_preview_page.dart new file mode 100644 index 0000000..0cad2f8 --- /dev/null +++ b/lib/presentation/pages/preview/pdf_preview_page.dart @@ -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 createState() => _PdfPreviewPageState(); +} + +class _PdfPreviewPageState extends State { + String? _pdfUrl; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadPdfUrl(); + } + + Future _loadPdfUrl() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: false, + entity: widget.entityId, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + 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, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/preview/video_preview_page.dart b/lib/presentation/pages/preview/video_preview_page.dart new file mode 100644 index 0000000..e9c208c --- /dev/null +++ b/lib/presentation/pages/preview/video_preview_page.dart @@ -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 createState() => _VideoPreviewPageState(); +} + +class _VideoPreviewPageState extends State { + 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 _loadVideoUrl() async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + download: false, + entity: widget.entityId, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + 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), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/preview/widgets/video_controls_overlay.dart b/lib/presentation/pages/preview/widgets/video_controls_overlay.dart new file mode 100644 index 0000000..42324c8 --- /dev/null +++ b/lib/presentation/pages/preview/widgets/video_controls_overlay.dart @@ -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 createState() => _VideoControlsOverlayState(); +} + +class _VideoControlsOverlayState extends State 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 _toggleFullscreen() async { + if (_isFullscreen) { + await _doExitFullscreen(); + } else { + await _doEnterFullscreen(); + } + if (mounted) setState(() {}); + } + + Future _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 _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( + 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( + 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( + stream: player.stream.position, + builder: (context, posSnapshot) { + return StreamBuilder( + 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( + 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( + 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( + stream: player.stream.position, + builder: (context, posSnapshot) { + return StreamBuilder( + 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(), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/profile/profile_page.dart b/lib/presentation/pages/profile/profile_page.dart new file mode 100644 index 0000000..523232d --- /dev/null +++ b/lib/presentation/pages/profile/profile_page.dart @@ -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 createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _initData(); + }); + } + + Future _initData() async { + final userSetting = context.read(); + userSetting.loadCapacity(); + + final authProvider = context.read(); + if (!authProvider.isAdmin) return; + + final adminProvider = context.read(); + 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 = []; + 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((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), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/profile/widgets/admin_section.dart b/lib/presentation/pages/profile/widgets/admin_section.dart new file mode 100644 index 0000000..78cf3c7 --- /dev/null +++ b/lib/presentation/pages/profile/widgets/admin_section.dart @@ -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( + 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, PaginationModel?)>( + selector: (_, p) => (p.groups, p.groupsPagination), + builder: (_, data, _) => _GroupsCard(groups: data.$1, pagination: data.$2), + ), + const SizedBox(height: 12), + Selector, PaginationModel?)>( + selector: (_, p) => (p.users, p.usersPagination), + builder: (_, data, _) => _UsersCard(users: data.$1, pagination: data.$2), + ), + ], + ); + }, + ), + ], + ); + } +} + +// ==================== 用户组卡片 ==================== + +class _GroupsCard extends StatelessWidget { + final List 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().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().deleteGroup(group.id).then((error) { + if (context.mounted) { + if (error != null) { + ToastHelper.failure(error); + } else { + ToastHelper.success('已删除'); + } + } + }); + }, + ), + ); + } +} + +// ==================== 用户卡片 ==================== + +class _UsersCard extends StatelessWidget { + final List 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(); + + return Selector( + 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().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().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 ids) { + showDialog( + context: context, + builder: (ctx) => _StyledConfirmDialog( + title: '批量删除用户', + message: '确定要删除选中的 ${ids.length} 个用户吗?此操作不可撤销。', + icon: LucideIcons.trash2, + isDestructive: true, + onConfirm: () { + Navigator.of(ctx).pop(); + context.read().batchDeleteUsers(ids).then((success) { + if (context.mounted) { + if (success) { + ToastHelper.success('已删除'); + } else { + ToastHelper.failure('删除失败'); + } + } + }); + }, + ), + ); + } +} + +/// 用户组 Chip 选择器 — 替代 DropdownButtonFormField +class _GroupChipSelector extends StatelessWidget { + final List groups; + final int? selectedGroupId; + final ValueChanged 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( + selector: (_, p) => (p.isSelectingUsers, p.isUserSelected(user.id)), + builder: (_, data, _) { + final isSelecting = data.$1; + final isSelected = data.$2; + final adminProvider = context.read(); + + 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), + ); +} diff --git a/lib/presentation/pages/profile/widgets/profile_info_card.dart b/lib/presentation/pages/profile/widgets/profile_info_card.dart new file mode 100644 index 0000000..32353cb --- /dev/null +++ b/lib/presentation/pages/profile/widgets/profile_info_card.dart @@ -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(); + 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( + 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')}'; + } +} diff --git a/lib/presentation/pages/profile/widgets/quick_functions_section.dart b/lib/presentation/pages/profile/widgets/quick_functions_section.dart new file mode 100644 index 0000000..6a3bc38 --- /dev/null +++ b/lib/presentation/pages/profile/widgets/quick_functions_section.dart @@ -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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/profile/widgets/thick_storage_bar.dart b/lib/presentation/pages/profile/widgets/thick_storage_bar.dart new file mode 100644 index 0000000..7b2c381 --- /dev/null +++ b/lib/presentation/pages/profile/widgets/thick_storage_bar.dart @@ -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(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'; + } +} diff --git a/lib/presentation/pages/recycle_bin/recycle_bin_page.dart b/lib/presentation/pages/recycle_bin/recycle_bin_page.dart new file mode 100644 index 0000000..2968e92 --- /dev/null +++ b/lib/presentation/pages/recycle_bin/recycle_bin_page.dart @@ -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 createState() => _RecycleBinPageState(); +} + +class _RecycleBinPageState extends State + with GestureHandlerMixin { + List _files = []; + Set _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 _refreshFiles() async { + await _loadFiles(); + } + + Future _loadFiles() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await FileService().listTrashFiles(page: 0); + final filesData = response['files'] as List? ?? []; + final files = filesData + .map((f) => FileModel.fromJson(f as Map)) + .toList(); + + setState(() { + _files = files; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + + Future _restoreFile(BuildContext context, FileModel file) async { + final confirmed = await showDialog( + 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 _restoreSelected() async { + final confirmed = await showDialog( + 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 _performRestore(List uris) async { + try { + await FileService().restoreFiles(uris: uris); + + if (mounted) { + ToastHelper.success('恢复成功'); + await _loadFiles(); + } + } catch (e) { + if (mounted) { + ToastHelper.failure('恢复失败: $e'); + } + } + } + + Future _deleteFile(BuildContext context, FileModel file) async { + final confirmed = await showDialog( + 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 _deleteSelected() async { + final confirmed = await showDialog( + 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 _performDelete(List 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, +} diff --git a/lib/presentation/pages/remote_download/remote_download_page.dart b/lib/presentation/pages/remote_download/remote_download_page.dart new file mode 100644 index 0000000..8855ad6 --- /dev/null +++ b/lib/presentation/pages/remote_download/remote_download_page.dart @@ -0,0 +1,1688 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; +import 'package:cloudreve4_flutter/presentation/widgets/folder_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../../data/models/remote_download_task_model.dart'; +import '../../../services/remote_download_service.dart'; +import '../../providers/file_manager_provider.dart'; +import '../../providers/navigation_provider.dart'; +import '../../widgets/toast_helper.dart'; + +class _StatusColors { + final Color background; + final Color foreground; + final Color icon; + const _StatusColors({ + required this.background, + required this.foreground, + required this.icon, + }); +} + +/// 离线下载页面 +class RemoteDownloadPage extends StatefulWidget { + const RemoteDownloadPage({super.key}); + + @override + State createState() => _RemoteDownloadPageState(); +} + +class _RemoteDownloadPageState extends State + with SingleTickerProviderStateMixin { + final RemoteDownloadService _service = RemoteDownloadService(); + + List _downloadingTasks = []; + List _completedTasks = []; + bool _isLoading = false; + Timer? _pollTimer; + + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + late TabController _tabController; + + static const double _desktopBreakpoint = 800; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_onTabChanged); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadTasks()); + } + + @override + void dispose() { + _pollTimer?.cancel(); + _searchController.dispose(); + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (_tabController.indexIsChanging) return; + _startOrStopPolling(); + } + + void _startOrStopPolling() { + _pollTimer?.cancel(); + int refreshTime = 1; + if (_tabController.index == 0 && _downloadingTasks.isNotEmpty) { + _pollTimer = Timer.periodic( + Duration(seconds: refreshTime), (_) => _loadTasks(quiet: true)); + } + } + + Future _loadTasks({bool quiet = false}) async { + if (!mounted) return; + if (!quiet) setState(() => _isLoading = true); + + try { + final results = await Future.wait([ + _service.listTasks(category: 'downloading', pageSize: 50), + _service.listTasks(category: 'downloaded', pageSize: 50), + ]); + + if (!mounted) return; + + final dlData = results[0]; + final cdData = results[1]; + + final dlTasks = (dlData['tasks'] as List? ?? []) + .map((t) => + RemoteDownloadTaskModel.fromJson(t as Map)) + .toList(); + final cdTasks = (cdData['tasks'] as List? ?? []) + .map((t) => + RemoteDownloadTaskModel.fromJson(t as Map)) + .toList(); + + setState(() { + _downloadingTasks = dlTasks; + _completedTasks = cdTasks; + _isLoading = false; + }); + + _startOrStopPolling(); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + if (!quiet) ToastHelper.failure('加载失败: $e'); + } + } + + // ─── 辅助方法 ─── + + _StatusColors _getStatusColors(RemoteDownloadStatus status) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + switch (status) { + case RemoteDownloadStatus.queued: + return _StatusColors( + background: isDark + ? Colors.grey.withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.1), + foreground: isDark ? Colors.grey.shade400 : Colors.grey.shade700, + icon: isDark ? Colors.grey.shade400 : Colors.grey.shade600, + ); + case RemoteDownloadStatus.running: + return _StatusColors( + background: colorScheme.primary.withValues(alpha: 0.1), + foreground: colorScheme.primary, + icon: colorScheme.primary, + ); + case RemoteDownloadStatus.completed: + return _StatusColors( + background: Colors.green.withValues(alpha: isDark ? 0.2 : 0.1), + foreground: isDark ? Colors.green.shade300 : Colors.green.shade700, + icon: isDark ? Colors.green.shade300 : Colors.green.shade600, + ); + case RemoteDownloadStatus.error: + return _StatusColors( + background: colorScheme.error.withValues(alpha: 0.1), + foreground: colorScheme.error, + icon: colorScheme.error, + ); + case RemoteDownloadStatus.suspending: + case RemoteDownloadStatus.suspended: + return _StatusColors( + background: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1), + foreground: + isDark ? Colors.orange.shade300 : Colors.orange.shade700, + icon: isDark ? Colors.orange.shade300 : Colors.orange.shade600, + ); + } + } + + IconData _getStatusIcon(RemoteDownloadStatus status) { + switch (status) { + case RemoteDownloadStatus.queued: + return LucideIcons.clock; + case RemoteDownloadStatus.running: + return LucideIcons.download; + case RemoteDownloadStatus.completed: + return LucideIcons.checkCircle2; + case RemoteDownloadStatus.error: + return LucideIcons.alertCircle; + case RemoteDownloadStatus.suspending: + case RemoteDownloadStatus.suspended: + return LucideIcons.pause; + } + } + + Widget _buildCountBadge(int count) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + constraints: const BoxConstraints(minWidth: 24), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + '$count', + key: ValueKey(count), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + ), + ); + } + + Widget _buildStatusBadge(RemoteDownloadStatus status) { + final colors = _getStatusColors(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + status.text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: colors.foreground, + ), + ), + ); + } + + Widget _buildLeadingIcon(RemoteDownloadStatus status) { + final colors = _getStatusColors(status); + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(_getStatusIcon(status), color: colors.icon, size: 18), + ); + } + + // ─── 页面构建 ─── + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: AppBar( + title: const Text('离线下载', + style: TextStyle(fontWeight: FontWeight.bold)), + elevation: 0, + centerTitle: !isDesktop, + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('下载中'), + const SizedBox(width: 8), + _buildCountBadge(_downloadingTasks.length), + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('已完成'), + const SizedBox(width: 8), + _buildCountBadge(_completedTasks.length), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(LucideIcons.refreshCw), + onPressed: () => _loadTasks(), + tooltip: '刷新', + ), + if (isDesktop) const SizedBox(width: 16), + ], + ), + body: Column( + children: [ + _buildHeaderBar(isDesktop), + Expanded( + child: _buildBody(context, isDesktop), + ), + ], + ), + floatingActionButton: isDesktop + ? null + : FloatingActionButton.extended( + onPressed: () => _showCreateDialog(context), + label: const Text('新建任务'), + icon: const Icon(LucideIcons.plus), + ), + ); + } + + Widget _buildHeaderBar(bool isDesktop) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 40, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '搜索任务...', + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + prefixIcon: const Icon(LucideIcons.search, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + onChanged: (value) { + _searchQuery = value.toLowerCase(); + setState(() {}); + }, + ), + ), + ), + if (isDesktop) ...[ + const SizedBox(width: 16), + Text( + '下载中 ${_downloadingTasks.length} / 已完成 ${_completedTasks.length}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(width: 16), + FilledButton.icon( + onPressed: () => _showCreateDialog(context), + icon: const Icon(LucideIcons.plus, size: 18), + label: const Text('新建任务'), + ), + ], + ], + ), + ); + } + + Widget _buildBody(BuildContext context, bool isDesktop) { + final filteredDownloading = _searchQuery.isEmpty + ? _downloadingTasks + : _downloadingTasks + .where((t) => t.displayName.toLowerCase().contains(_searchQuery)) + .toList(); + final filteredCompleted = _searchQuery.isEmpty + ? _completedTasks + : _completedTasks + .where((t) => t.displayName.toLowerCase().contains(_searchQuery)) + .toList(); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + child: _isLoading && + _downloadingTasks.isEmpty && + _completedTasks.isEmpty + ? const Center( + key: ValueKey('loading'), + child: CircularProgressIndicator(), + ) + : _searchQuery.isNotEmpty && + filteredDownloading.isEmpty && + filteredCompleted.isEmpty + ? _buildNoSearchResult() + : TabBarView( + key: const ValueKey('content'), + controller: _tabController, + children: [ + _buildTaskList(filteredDownloading, + isOngoing: true, isDesktop: isDesktop), + _buildTaskList(filteredCompleted, + isOngoing: false, isDesktop: isDesktop), + ], + ), + ); + } + + Widget _buildTaskList(List tasks, + {required bool isOngoing, required bool isDesktop}) { + if (tasks.isEmpty) { + return _buildEmptyState(isOngoing: isOngoing); + } + + return RefreshIndicator( + onRefresh: () => _loadTasks(), + child: isDesktop + ? _buildDesktopLayout(tasks, isOngoing) + : _buildMobileLayout(tasks, isOngoing), + ); + } + + // ─── 桌面端布局 ─── + + Widget _buildDesktopLayout( + List tasks, bool isOngoing) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: SizedBox( + width: double.infinity, + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: isOngoing + ? _buildDownloadingTable(tasks) + : _buildCompletedTable(tasks), + ), + ), + ); + } + + Widget _buildDownloadingTable(List tasks) { + final colorScheme = Theme.of(context).colorScheme; + + return DataTable( + showCheckboxColumn: false, + 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: tasks.map((task) { + final download = task.summary?.download; + return DataRow( + onSelectChanged: (_) => _showTaskDetail(task), + cells: [ + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Row( + children: [ + _buildLeadingIcon(task.status), + const SizedBox(width: 12), + Expanded( + child: Text( + task.displayName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ), + DataCell(_buildStatusBadge(task.status)), + DataCell( + download != null && download.total > 0 + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 100, + child: LinearProgressIndicator( + value: download.progress.clamp(0.0, 1.0), + backgroundColor: + colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary), + ), + ), + const SizedBox(width: 8), + Text( + '${(download.progress * 100).toStringAsFixed(1)}%', + style: const TextStyle(fontSize: 12)), + ], + ) + : const Text('-', style: TextStyle(fontSize: 12)), + ), + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (download != null) + Text( + '${_formatSize(download.downloaded)} / ${_formatSize(download.total)}', + style: TextStyle( + fontSize: 12, color: colorScheme.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), + if (download != null && download.speedText.isNotEmpty) + Text( + download.speedText, + style: TextStyle( + fontSize: 11, + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + DataCell(Text(_formatTime(task.createdAt), + style: TextStyle( + fontSize: 12, color: colorScheme.onSurfaceVariant))), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (download != null && download.files.isNotEmpty) + IconButton( + icon: const Icon(LucideIcons.layoutList, size: 18), + onPressed: () { + if (!mounted) return; + Future.microtask(() { + if (!mounted) return; + _showFilesDialog(context, task); + }); + }, + tooltip: '查看文件', + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(32, 32), + ), + ), + if (task.status.isOngoing) + IconButton( + icon: Icon(LucideIcons.xCircle, + size: 18, color: colorScheme.error), + onPressed: () => _cancelTask(task), + tooltip: '取消任务', + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(32, 32), + ), + ), + ], + ), + ), + ], + ); + }).toList(), + ); + } + + Widget _buildCompletedTable(List tasks) { + final colorScheme = Theme.of(context).colorScheme; + + return DataTable( + showCheckboxColumn: false, + 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))), + ], + rows: tasks.map((task) { + final download = task.summary?.download; + return DataRow( + onSelectChanged: (_) => _showTaskDetail(task), + cells: [ + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Row( + children: [ + _buildLeadingIcon(task.status), + const SizedBox(width: 12), + Expanded( + child: Text( + task.displayName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ), + DataCell(_buildStatusBadge(task.status)), + DataCell( + download != null + ? Text(_formatSize(download.total), + style: TextStyle( + fontSize: 12, color: colorScheme.onSurfaceVariant)) + : const Text('-', style: TextStyle(fontSize: 12)), + ), + DataCell(Text(_formatTime(task.createdAt), + style: TextStyle( + fontSize: 12, color: colorScheme.onSurfaceVariant))), + DataCell( + download != null && download.files.isNotEmpty + ? IconButton( + icon: const Icon(LucideIcons.layoutList, size: 18), + onPressed: () => _showFilesDialog(context, task), + tooltip: '查看文件', + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(32, 32), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + }).toList(), + ); + } + + // ─── 移动端布局 ─── + + Widget _buildMobileLayout( + List tasks, bool isOngoing) { + final theme = Theme.of(context); + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: tasks.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 64, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + itemBuilder: (context, index) => + _buildTaskCard(tasks[index], isOngoing), + ); + } + + Widget _buildTaskCard(RemoteDownloadTaskModel task, bool isOngoing) { + final download = task.summary?.download; + final hasFiles = download != null && download.files.isNotEmpty; + final colorScheme = Theme.of(context).colorScheme; + final colors = _getStatusColors(task.status); + final theme = Theme.of(context); + + return InkWell( + onTap: () => _showTaskDetail(task), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Left: status icon + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(_getStatusIcon(task.status), + color: colors.icon, size: 18), + ), + const SizedBox(width: 12), + // Middle: name + status/speed/size + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.displayName, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _buildMobileSubtitle(task, isOngoing), + style: theme.textTheme.bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + // Right: more button + IconButton( + icon: const Icon(LucideIcons.moreVertical, size: 18), + onPressed: () => + _showMobileActionMenu(task, isOngoing, hasFiles), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(32, 32), + ), + ), + ], + ), + // Progress bar for downloading tasks + if (isOngoing && download != null && download.total > 0) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 48), + child: LinearProgressIndicator( + value: download.progress.clamp(0.0, 1.0), + minHeight: 4, + backgroundColor: colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary), + ), + ), + ], + // Error text + if (task.error != null && task.error!.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 48), + child: Text( + task.error!, + style: TextStyle(fontSize: 12, color: colorScheme.error), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + ); + } + + String _buildMobileSubtitle(RemoteDownloadTaskModel task, bool isOngoing) { + final download = task.summary?.download; + final parts = [task.status.text]; + + if (isOngoing && download != null) { + if (download.speedText.isNotEmpty) { + parts.add(download.speedText); + } + if (download.total > 0) { + parts.add( + '${_formatSize(download.downloaded)} / ${_formatSize(download.total)}'); + } + } else if (!isOngoing && download != null) { + parts.add(_formatSize(download.total)); + } + + return parts.join(' · '); + } + + // ─── 空状态 / 搜索无结果 ─── + + Widget _buildEmptyState({required bool isOngoing}) { + final colorScheme = Theme.of(context).colorScheme; + + return RefreshIndicator( + onRefresh: () => _loadTasks(), + child: ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.3), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isOngoing + ? LucideIcons.download + : LucideIcons.checkCircle2, + size: 80, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + isOngoing ? '暂无下载中的任务' : '暂无已完成的任务', + style: TextStyle( + fontSize: 16, color: Theme.of(context).hintColor), + ), + const SizedBox(height: 8), + Text( + isOngoing ? '点击右下角按钮新建任务' : '下载完成的任务将显示在这里', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).hintColor.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildNoSearchResult() { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + key: const ValueKey('no_result'), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.searchX, size: 64, color: colorScheme.outline), + const SizedBox(height: 16), + Text( + '没有找到 "$_searchQuery"', + style: TextStyle( + fontSize: 16, color: Theme.of(context).hintColor), + ), + ], + ), + ); + } + + // ─── 移动端操作菜单 ─── + + void _showMobileActionMenu( + RemoteDownloadTaskModel task, bool isOngoing, bool hasFiles) { + 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: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Text( + task.displayName, + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(LucideIcons.info), + title: const Text('查看详情'), + onTap: () { + Navigator.pop(context); + _showTaskDetail(task); + }, + ), + if (hasFiles) + ListTile( + leading: const Icon(LucideIcons.layoutList), + title: const Text('查看文件'), + onTap: () { + Navigator.pop(context); + _showFilesDialog(this.context, task); + }, + ), + if (isOngoing && task.status.isOngoing) + ListTile( + leading: Icon(LucideIcons.xCircle, color: colorScheme.error), + title: Text('取消任务', + style: TextStyle(color: colorScheme.error)), + onTap: () { + Navigator.pop(context); + _cancelTask(task); + }, + ), + ], + ), + ), + ); + } + + // ─── 任务详情抽屉 ─── + + void _showTaskDetail(RemoteDownloadTaskModel task) { + final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint; + final colorScheme = Theme.of(context).colorScheme; + final download = task.summary?.download; + final hasFiles = download != null && download.files.isNotEmpty; + + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '任务详情', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final tween = Tween(begin: const Offset(1, 0), end: Offset.zero); + return SlideTransition( + position: tween.animate(CurvedAnimation( + parent: animation, curve: Curves.easeOutCubic)), + child: child, + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + return Align( + alignment: Alignment.centerRight, + child: Material( + color: colorScheme.surface, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16)), + child: SafeArea( + right: false, + bottom: false, + child: Container( + width: isDesktop ? 420 : MediaQuery.of(context).size.width * 0.85, + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16)), + border: Border( + left: BorderSide( + color: colorScheme.outlineVariant + .withValues(alpha: 0.5))), + ), + child: Column( + children: [ + _buildDetailHeader(task, colorScheme), + const Divider(height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + _buildDetailSection('基本信息', [ + _buildDetailRow( + icon: LucideIcons.clock, + label: '创建于', + value: _formatDateTime(task.createdAt), + ), + _buildDetailRow( + icon: LucideIcons.refreshCw, + label: '更新于', + value: _formatDateTime(task.updatedAt), + ), + _buildDetailRow( + icon: _getStatusIcon(task.status), + label: '状态', + value: task.status.text, + valueWidget: _buildStatusBadge(task.status), + ), + if (task.node != null) + _buildDetailRow( + icon: LucideIcons.server, + label: '处理节点', + value: task.node!.displayName, + ), + ]), + const SizedBox(height: 20), + _buildDetailSection('下载信息', [ + _buildDetailRow( + icon: LucideIcons.logIn, + label: '输入', + value: task.srcDisplayText, + copyable: true, + ), + _buildDetailRow( + icon: LucideIcons.logOut, + label: '输出', + value: task.dstDisplayText, + navigable: true, + onNavigate: () { + Navigator.of(context).pop(); + _navigateToFolder(task); + }, + ), + _buildDetailRow( + icon: LucideIcons.timer, + label: '执行净耗时', + value: task.durationText, + ), + _buildDetailRow( + icon: LucideIcons.refreshCw, + label: '重试次数', + value: '${task.retryCount}', + ), + if (download != null && download.numPieces > 0) + _buildDetailRow( + icon: LucideIcons.layoutGrid, + label: '分片数量', + value: '${download.numPieces}', + ), + if (download != null && download.total > 0) ...[ + _buildDetailRow( + icon: LucideIcons.hardDrive, + label: '总大小', + value: + '${_formatSize(download.downloaded)} / ${_formatSize(download.total)}', + ), + if (download.speedText.isNotEmpty) + _buildDetailRow( + icon: LucideIcons.gauge, + label: '下载速度', + value: download.speedText, + ), + ], + ]), + if (task.error != null && + task.error!.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildDetailSection('错误信息', [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + task.error!, + style: TextStyle( + fontSize: 13, + color: colorScheme.onErrorContainer), + ), + ), + ]), + ], + const SizedBox(height: 20), + // 操作按钮 + Row( + children: [ + if (hasFiles) + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _showFilesDialog(this.context, task); + }, + icon: const Icon(LucideIcons.layoutList, size: 18), + label: const Text('查看文件'), + ), + ), + if (hasFiles && + task.status.isOngoing) + const SizedBox(width: 12), + if (task.status.isOngoing) + Expanded( + child: FilledButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _cancelTask(task); + }, + icon: Icon(LucideIcons.xCircle, size: 18), + label: const Text('取消任务'), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), // Container + ), // SafeArea + ), + ); + }, + ); + } + + Widget _buildDetailHeader( + RemoteDownloadTaskModel task, ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 16), + child: Row( + children: [ + _buildLeadingIcon(task.status), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.displayName, + style: const TextStyle( + fontWeight: FontWeight.w700, fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'ID: ${task.id}', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace'), + ), + ], + ), + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.of(context).pop(), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ); + } + + Widget _buildDetailSection( + String title, List children) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + ...children, + ], + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + Widget? valueWidget, + bool copyable = false, + bool navigable = false, + VoidCallback? onNavigate, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 10), + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + fontSize: 13, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 8), + Expanded( + child: valueWidget ?? + Row( + children: [ + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 13), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + if (copyable && value != '-') + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ToastHelper.success('已复制到剪贴板'); + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(LucideIcons.copy, + size: 14, color: colorScheme.primary), + ), + ), + if (navigable && value != '-') ...[ + const SizedBox(width: 4), + InkWell( + onTap: onNavigate, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(LucideIcons.externalLink, + size: 14, color: colorScheme.primary), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + void _navigateToFolder(RemoteDownloadTaskModel task) { + final dst = task.summary?.dst ?? ''; + if (dst.isEmpty) return; + + String relativePath; + if (dst.startsWith('cloudreve://my')) { + relativePath = dst.replaceFirst('cloudreve://my', ''); + if (relativePath.isEmpty) relativePath = '/'; + } else { + relativePath = dst; + } + + try { + final fileManager = Provider.of( + context, + listen: false, + ); + final navProvider = Provider.of( + context, + listen: false, + ); + + // 计算父目录路径用于导航,高亮该目录本身 + final parentPath = _getParentPath(relativePath); + fileManager.navigateAndHighlight(parentPath, dst); + navProvider.setIndex(1); + Navigator.of(context).popUntil((route) => + route.settings.name == '/home' || route.isFirst); + } catch (e) { + ToastHelper.info('目标路径: $relativePath'); + } + } + + String _getParentPath(String path) { + if (path.isEmpty || path == '/') return '/'; + final parts = path.split('/')..removeLast(); + final result = parts.join('/'); + return result.isEmpty ? '/' : result; + } + + // ─── 对话框 ─── + + /// 创建任务对话框 + Future _showCreateDialog(BuildContext context) async { + final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint; + String selectedDst = '/'; + bool folderSelected = false; + final srcController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isDesktop ? 40 : MediaQuery.of(ctx).size.width * 0.05, + ), + title: const Text('新建离线下载'), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop ? 500 : MediaQuery.of(ctx).size.width * 0.9, + maxHeight: MediaQuery.of(ctx).size.height * 0.75, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 上方:目录选择区域 + Align( + alignment: Alignment.centerLeft, + child: Text('选择保存目录', + style: TextStyle( + fontSize: 13, + color: Theme.of(ctx).colorScheme.onSurfaceVariant)), + ), + const SizedBox(height: 8), + if (folderSelected) ...[ + // 已选择:显示路径 + 修改按钮 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(ctx).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(LucideIcons.folder, size: 18, color: Theme.of(ctx).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + selectedDst == '/' ? '/ (根目录)' : selectedDst, + style: const TextStyle(fontSize: 14), + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => setDialogState(() => folderSelected = false), + child: const Text('修改'), + ), + ], + ), + ), + ] else ...[ + // 未选择:显示目录选择器 + Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.45, + ), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(ctx).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: FolderPicker( + currentPath: selectedDst, + maxVisibleItems: isDesktop ? null : 3, + onFolderSelected: (path) { + setDialogState(() { + selectedDst = path; + folderSelected = true; + }); + }, + ), + ), + ], + + const SizedBox(height: 16), + // 下方:下载链接 + TextField( + controller: srcController, + decoration: const InputDecoration( + labelText: '下载链接', + hintText: '输入 URL 或磁力链接,每行一个', + prefixIcon: Icon(LucideIcons.link), + alignLabelWithHint: true, + ), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('创建'), + ), + ], + ), + ), + ); + + if (!mounted || confirmed != true) return; + + final srcText = srcController.text.trim(); + if (srcText.isEmpty) { + ToastHelper.error('请输入下载链接'); + return; + } + + final urls = srcText + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + if (urls.isEmpty) { + ToastHelper.error('请输入至少一个下载链接'); + return; + } + + final dst = _toCloudreveUri(selectedDst); + + try { + final tasks = await _service.createDownload(dst: dst, src: urls); + if (!mounted) return; + setState(() { + _downloadingTasks = [...tasks, ..._downloadingTasks]; + }); + _tabController.animateTo(0); + _startOrStopPolling(); + ToastHelper.success('已创建 ${tasks.length} 个任务'); + } catch (e) { + if (!mounted) return; + AppLogger.e('创建失败: $e'); + ToastHelper.failure('创建失败: $e'); + } + } + + /// 将相对路径转为 cloudreve URI 格式 + 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'; + } + + /// 文件选择对话框(毛玻璃风格) + Future _showFilesDialog( + BuildContext context, RemoteDownloadTaskModel task) async { + final download = task.summary?.download; + if (download == null || download.files.isEmpty) return; + final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint; + final colorScheme = Theme.of(context).colorScheme; + + final selected = { + for (final f in download.files) f.index: f.selected, + }; + + await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '文件列表', + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (dialogContext, animation, secondaryAnimation) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final dialogWidth = isDesktop ? 480.0 : screenWidth - 48.0; + final maxDialogHeight = screenHeight * 0.7; + final minContentHeight = download.files.length * 56.0; + final contentHeight = minContentHeight.clamp(200.0, maxDialogHeight - 120); + + return Center( + child: Container( + width: dialogWidth, + constraints: BoxConstraints( + maxHeight: maxDialogHeight, + minHeight: 280, + ), + child: StatefulBuilder( + builder: (ctx, setDialogState) { + final theme = Theme.of(ctx); + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: theme.brightness == Brightness.light + ? Colors.white.withValues(alpha: 0.7) + : Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.brightness == Brightness.light + ? Colors.white.withValues(alpha: 0.3) + : Colors.white.withValues(alpha: 0.1), + ), + ), + 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(LucideIcons.layoutList, size: 20, color: colorScheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + download.name.isNotEmpty ? download.name : '文件列表', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ), + const Divider(height: 1), + // File list - content fills available space + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: contentHeight, + minHeight: 160, + ), + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: download.files.length, + itemBuilder: (context, index) { + final file = download.files[index]; + return CheckboxListTile( + value: selected[file.index] ?? file.selected, + title: Text(file.name, + style: const TextStyle(fontSize: 13), + maxLines: 2, + overflow: TextOverflow.ellipsis), + subtitle: Text(_formatSize(file.size), + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant)), + controlAffinity: ListTileControlAffinity.leading, + dense: true, + onChanged: (v) => + setDialogState(() => selected[file.index] = v ?? true), + ); + }, + ), + ), + const Divider(height: 1), + // Actions + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: const Text('关闭'), + ), + if (task.status.isOngoing) ...[ + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + final changes = >[]; + for (final file in download.files) { + final wasSelected = file.selected; + final nowSelected = selected[file.index] ?? wasSelected; + if (wasSelected != nowSelected) { + changes.add({'index': file.index, 'download': nowSelected}); + } + } + if (changes.isEmpty) { + Navigator.of(ctx).pop(); + return; + } + try { + await _service.selectFiles(taskId: task.id, files: changes); + if (!mounted || !ctx.mounted) return; + Navigator.of(ctx).pop(); + ToastHelper.success('文件选择已更新'); + _loadTasks(); + } catch (e) { + if (!mounted) return; + ToastHelper.failure('更新失败: $e'); + } + }, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: const Text('保存'), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + }, + ); + } + + /// 取消任务 + Future _cancelTask(RemoteDownloadTaskModel task) async { + final colorScheme = Theme.of(context).colorScheme; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('取消任务'), + content: Text('确定要取消离线下载任务"${task.displayName}"吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('否')), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: + FilledButton.styleFrom(backgroundColor: colorScheme.error), + child: const Text('取消任务'), + ), + ], + ), + ); + + if (!mounted || confirmed != true) return; + + try { + await _service.cancelTask(taskId: task.id); + if (!mounted) return; + ToastHelper.success('任务已取消'); + _loadTasks(); + } catch (e) { + if (!mounted) return; + ToastHelper.failure('取消失败: $e'); + } + } + + String _formatSize(int bytes) { + if (bytes <= 0) { + return '0 B'; + } + 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 _formatTime(DateTime time) { + final now = DateTime.now(); + final diff = now.difference(time); + if (diff.inSeconds < 60) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${time.month}/${time.day} ${time.hour}:${time.minute.toString().padLeft(2, '0')}'; + } + + String _formatDateTime(DateTime time) { + return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} ' + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/presentation/pages/settings/app_settings_page.dart b/lib/presentation/pages/settings/app_settings_page.dart new file mode 100644 index 0000000..a5ed785 --- /dev/null +++ b/lib/presentation/pages/settings/app_settings_page.dart @@ -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 createState() => _AppSettingsPageState(); +} + +class _AppSettingsPageState extends State { + 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 _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 _saveCacheSettings() async { + final service = CacheManagerService.instance; + await service.saveSettings(_cacheSettings); + if (mounted) ToastHelper.success('设置已保存'); + } + + Future _loadLogInfo() async { + final path = await AppLogger.logFilePath; + final size = await AppLogger.logFileSize; + if (mounted) { + setState(() { + _logFilePath = path; + _logFileSize = size; + }); + } + } + + Future _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 _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().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() + .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 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 _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().seedColor; + + final selected = await showDialog( + 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().setSeedColor(selected); + if (!context.mounted) return; + + // 同步到服务端 + final hex = '#${selected.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}'; + final success = await context.read().updatePreferredTheme(hex); + if (!mounted) return; + if (success) { + ToastHelper.success('主题色已更新'); + } else { + ToastHelper.failure('同步主题色到服务端失败'); + } + } + + String _themeModeLabel(BuildContext context) { + final mode = context.watch().themeMode; + return switch (mode) { + AppThemeMode.light => '浅色', + AppThemeMode.dark => '深色', + AppThemeMode.system => '跟随系统', + }; + } + + Future _showThemeModeDialog(BuildContext context) async { + final currentMode = context.read().themeMode; + final options = [ + (AppThemeMode.system, '跟随系统', Icons.brightness_auto), + (AppThemeMode.light, '浅色', Icons.light_mode), + (AppThemeMode.dark, '深色', Icons.dark_mode), + ]; + + final selected = await showDialog( + 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().setThemeMode(selected); + } + + Future _showLanguageDialog(BuildContext context) async { + final languages = [ + ('zh-CN', '简体中文'), + ('zh-TW', '繁體中文'), + ('en-US', 'English'), + ('ja-JP', '日本語'), + ]; + + final selected = await showDialog( + 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().updateLanguage(selected); + if (!mounted) return; + if (success) { + ToastHelper.success('语言偏好已保存'); + } else { + ToastHelper.failure('更新语言失败'); + } + } + + Future _showMaxCacheSizeDialog(BuildContext context) async { + final availableSizes = CacheSettingsModel.availableSizes; + final currentValue = _cacheSettings.maxCacheSize ~/ (1024 * 1024); + + final selected = await _showGlassOptionDialog( + 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 _showCacheExpireDurationDialog(BuildContext context) async { + final availableDurations = CacheSettingsModel.availableDurations; + final currentValue = _cacheSettings.cacheExpireDuration ~/ (24 * 60 * 60 * 1000); + + final selected = await _showGlassOptionDialog( + 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 _showRetriesDialog(BuildContext context) async { + final retriesOptions = [0, 1, 2, 3, 5, 10]; + + final selected = await _showGlassOptionDialog( + 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 _showRetentionDaysDialog(BuildContext context) async { + final options = [ + (7, '7 天'), + (15, '15 天'), + (30, '30 天'), + (-1, '永久保留'), + ]; + + final selected = await _showGlassOptionDialog( + 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 _showGlassOptionDialog( + BuildContext context, { + required String title, + required IconData icon, + String? subtitle, + required List<(T, String, bool)> options, + }) { + return showGeneralDialog( + 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 _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( + 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 _clearCache() async { + final confirmed = await showDialog( + 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 _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 _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 _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 _previewLog() async { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LogViewerPage()), + ); + } + + Future _clearLog() async { + final confirmed = await showDialog( + 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('日志已清空'); + } + } +} diff --git a/lib/presentation/pages/settings/credit_history_page.dart b/lib/presentation/pages/settings/credit_history_page.dart new file mode 100644 index 0000000..6d4b6e1 --- /dev/null +++ b/lib/presentation/pages/settings/credit_history_page.dart @@ -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 createState() => _CreditHistoryPageState(); +} + +class _CreditHistoryPageState extends State { + final List _changes = []; + String? _nextToken; + bool _isLoading = true; + bool _isLoadingMore = false; + bool get _hasMore => _nextToken != null && _nextToken!.isNotEmpty; + + @override + void initState() { + super.initState(); + _loadChanges(); + } + + Future _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 _loadMore() async { + if (_isLoadingMore || !_hasMore) return; + setState(() => _isLoadingMore = true); + await _loadChanges(); + } + + Future _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')}'; + } +} diff --git a/lib/presentation/pages/settings/file_preferences_page.dart b/lib/presentation/pages/settings/file_preferences_page.dart new file mode 100644 index 0000000..a5c5f7c --- /dev/null +++ b/lib/presentation/pages/settings/file_preferences_page.dart @@ -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 createState() => _FilePreferencesPageState(); +} + +class _FilePreferencesPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadSettings(); + }); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + 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().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().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 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 _showShareVisibilityDialog(BuildContext context, UserSettingModel? settings) async { + final currentValue = settings?.shareLinksInProfile ?? ''; + final options = [ + ('', '仅公开分享', '仅在个人主页显示公开分享'), + ('all_share', '所有分享链接', '在个人主页显示所有分享'), + ('hide_share', '隐藏分享链接', '不在个人主页显示任何分享'), + ]; + + final selected = await showDialog( + 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().updateShareLinksInProfile(selected); + if (!mounted) return; + if (success) { + ToastHelper.success('已更新'); + } else { + ToastHelper.failure('更新失败'); + } + } + + Future _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>( + 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().updateVersionRetention(max: max); + if (!mounted) return; + if (success) { + ToastHelper.success('已更新'); + } else { + ToastHelper.failure('更新失败'); + } + } + + Future _showExtEditor(BuildContext context, UserSettingModel? settings) async { + final exts = settings?.versionRetentionExt ?? []; + final controller = TextEditingController(text: exts.join(', ')); + + final confirmed = await showDialog( + 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? 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().updateVersionRetention(ext: newExts); + if (!mounted) return; + if (success) { + ToastHelper.success('已更新'); + } else { + ToastHelper.failure('更新失败'); + } + } +} diff --git a/lib/presentation/pages/settings/log_viewer_page.dart b/lib/presentation/pages/settings/log_viewer_page.dart new file mode 100644 index 0000000..7d0f7c2 --- /dev/null +++ b/lib/presentation/pages/settings/log_viewer_page.dart @@ -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 createState() => _LogViewerPageState(); +} + +class _LogViewerPageState extends State { + 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 _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], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/settings/profile_edit_page.dart b/lib/presentation/pages/settings/profile_edit_page.dart new file mode 100644 index 0000000..2365381 --- /dev/null +++ b/lib/presentation/pages/settings/profile_edit_page.dart @@ -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 createState() => _ProfileEditPageState(); +} + +class _ProfileEditPageState extends State { + bool _isUploadingAvatar = false; + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + 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 _showAvatarOptions() async { + final result = await showModalBottomSheet( + 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 _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().refreshUser(); + // 清除头像缓存,使其他页面的 UserAvatar 刷新 + if (!mounted) return; + final userId = context.read().user?.id ?? ''; + await UserAvatar.evictCache(userId); + + if (mounted) { + setState(() => _isUploadingAvatar = false); + ToastHelper.success('头像已更新'); + } + } catch (e) { + if (mounted) { + setState(() => _isUploadingAvatar = false); + ToastHelper.failure('上传头像失败: $e'); + } + } + } + + Future _resetToGravatar() async { + try { + setState(() => _isUploadingAvatar = true); + final service = UserSettingService.instance; + await service.updateAvatar(null); + if (!mounted) return; + await context.read().refreshUser(); + if (!mounted) return; + // 清除头像缓存,使其他页面的 UserAvatar 刷新 + final userId = context.read().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 _showEditNickDialog(BuildContext context, UserModel? user) async { + final controller = TextEditingController(text: user?.nickname ?? ''); + final formKey = GlobalKey(); + + final confirmed = await showDialog( + 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().updateNick(newNick); + if (!mounted) return; + if (!context.mounted) return; + if (success) { + // 同步刷新 AuthProvider 中的用户信息 + await context.read().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')}'; + } +} diff --git a/lib/presentation/pages/settings/quick_access_settings_page.dart b/lib/presentation/pages/settings/quick_access_settings_page.dart new file mode 100644 index 0000000..710f1c7 --- /dev/null +++ b/lib/presentation/pages/settings/quick_access_settings_page.dart @@ -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 createState() => _QuickAccessSettingsPageState(); +} + +class _QuickAccessSettingsPageState extends State { + List _items = []; + bool _isLoaded = false; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + Future _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 _save() async { + await StorageService.instance.setString( + QuickAccessConfig.storageKey, + QuickAccessConfig.serialize(_items), + ); + } + + Future _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 _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); +} diff --git a/lib/presentation/pages/settings/security_settings_page.dart b/lib/presentation/pages/settings/security_settings_page.dart new file mode 100644 index 0000000..fcfd939 --- /dev/null +++ b/lib/presentation/pages/settings/security_settings_page.dart @@ -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 createState() => _SecuritySettingsPageState(); +} + +class _SecuritySettingsPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadSettings(); + }); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + 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 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 _showChangePasswordDialog(BuildContext context) async { + final currentCtrl = TextEditingController(); + final newCtrl = TextEditingController(); + final confirmCtrl = TextEditingController(); + final formKey = GlobalKey(); + bool obscureCurrent = true; + bool obscureNew = true; + + final confirmed = await showDialog( + 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().changePassword( + currentPassword: currentCtrl.text, + newPassword: newCtrl.text, + ); + if (!mounted) return; + + if (success) { + ToastHelper.success('密码已修改'); + } else { + ToastHelper.failure('修改密码失败,请检查当前密码是否正确'); + } + } + + // ---- 启用2FA ---- + Future _showEnable2FADialog(BuildContext context) async { + final codeCtrl = TextEditingController(); + String secret = ''; + try { + // 先获取 TOTP secret + final secretJsonString = await context.read().prepare2FA(); + final Map 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(); + 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().enable2FA(code); + if (!mounted) return; + if (success) { + ToastHelper.success('两步验证已启用'); + } else { + ToastHelper.failure('启用失败,请检查验证码是否正确'); + } + }, + child: const Text('启用'), + ), + ], + ), + ); + } + + // ---- 禁用2FA ---- + Future _showDisable2FADialog(BuildContext context) async { + final codeCtrl = TextEditingController(); + + final confirmed = await showDialog( + 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().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 _deletePasskey(BuildContext context, PasskeyModel passkey) async { + final confirmed = await showDialog( + 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().loadSettings(); + if (mounted) ToastHelper.success('Passkey 已删除'); + } catch (e) { + if (mounted) ToastHelper.failure('删除失败: $e'); + } + } + + // ---- 已关联账号 ---- + List _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 _unlinkOpenId(BuildContext context, OpenIdProvider oid) async { + final confirmed = await showDialog( + 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().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 _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 _revokeOAuth(BuildContext context, OAuthGrant grant) async { + final confirmed = await showDialog( + 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().revokeOAuthGrant(grant.clientId); + if (!mounted) return; + if (success) { + ToastHelper.success('已撤销 ${grant.clientName} 的授权'); + } else { + ToastHelper.failure('撤销授权失败'); + } + } + + // ---- 登录活动 ---- + List _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')}'; + } +} diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart new file mode 100644 index 0000000..6f53ef0 --- /dev/null +++ b/lib/presentation/pages/settings/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + String _appVersion = ''; + + @override + void initState() { + super.initState(); + _loadAppVersion(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadAll(); + }); + } + + Future _loadAppVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + if (mounted) setState(() => _appVersion = info.version); + } catch (_) {} + } + + Future _refresh() async { + await context.read().loadAll(); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final user = auth.user; + final settingProvider = context.watch(); + 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 _buildProSections(BuildContext context, UserSettingModel settings) { + final sections = []; + final hasStoragePacks = settings.storagePacks.isNotEmpty; + final hasCredit = settings.credit > 0; + final hasMembership = settings.groupExpires != null; + + if (hasStoragePacks || hasCredit || hasMembership) { + final children = []; + 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 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 = []; + if (settings.twoFaEnabled) items.add('2FA已启用'); + if (settings.passwordless) items.add('无密码登录'); + return items.isEmpty ? '密码、2FA' : items.join('、'); + } + + // ---- 存储包 ---- + String _storagePackSummary(List packs) { + final total = packs.fold(0, (sum, p) => sum + p.size); + return '共 ${_formatBytes(total)} · ${packs.length} 个存储包'; + } + + void _showStoragePacks(BuildContext context, List 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 _cancelMembership(BuildContext context) async { + final confirmed = await showDialog( + 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().loadSettings(); + if (mounted) ToastHelper.success('会员已取消'); + } catch (e) { + if (mounted) ToastHelper.failure('取消会员失败: $e'); + } + } + + Future _navigateTo(BuildContext context, Widget page) async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + if (!context.mounted) return; + if (mounted) { + context.read().loadAll(); + } + } + + Future _confirmLogout(BuildContext context, AuthProvider auth) async { + final confirmed = await showDialog( + 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, + ); + } +} diff --git a/lib/presentation/pages/shares/shares_page.dart b/lib/presentation/pages/shares/shares_page.dart new file mode 100644 index 0000000..0f0f8a1 --- /dev/null +++ b/lib/presentation/pages/shares/shares_page.dart @@ -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 createState() => _SharesPageState(); +} + +class _SharesPageState extends State { + List _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 _loadShares({bool isLoadMore = false}) async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await ShareService().listShares( + pageSize: _sharesPageListSize, + nextPageToken: isLoadMore ? _nextPageToken : null, + ); + + final List sharesData = + response['shares'] as List? ?? []; + final pagination = response['pagination'] as Map? ?? {}; + final newShares = sharesData + .map((s) => ShareModel.fromJson(s as Map)) + .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 _loadMoreShares() async { + if (!_hasMore || _isLoading) return; + await _loadShares(isLoadMore: true); + } + + Future _refreshShares() async { + final success = await _loadShares(isLoadMore: false); + if (mounted) { + if (success) { + ToastHelper.success('刷新成功'); + } else { + ToastHelper.failure('刷新失败'); + } + } + } + + Future _deleteShare(ShareModel share) async { + final colorScheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + 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 _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( + 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 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 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, + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/shell/app_shell.dart b/lib/presentation/pages/shell/app_shell.dart new file mode 100644 index 0000000..b175067 --- /dev/null +++ b/lib/presentation/pages/shell/app_shell.dart @@ -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 createState() => _AppShellState(); +} + +class _AppShellState extends State 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(context, listen: false); + final fileManager = Provider.of(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( + 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( + 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(); + 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( + 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 _handleLogout(BuildContext context) async { + final authProvider = Provider.of(context, listen: false); + final fileManager = Provider.of(context, listen: false); + + final confirmed = await showDialog( + 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); + } + } + } +} diff --git a/lib/presentation/pages/splash/splash_page.dart b/lib/presentation/pages/splash/splash_page.dart new file mode 100644 index 0000000..f480963 --- /dev/null +++ b/lib/presentation/pages/splash/splash_page.dart @@ -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 createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + _initApp(); + } + + Future _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(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 _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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/tasks/tasks_page.dart b/lib/presentation/pages/tasks/tasks_page.dart new file mode 100644 index 0000000..4ed444a --- /dev/null +++ b/lib/presentation/pages/tasks/tasks_page.dart @@ -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( + 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, + ), + ), + ], + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/presentation/pages/tasks/widgets/download_tasks_tab.dart b/lib/presentation/pages/tasks/widgets/download_tasks_tab.dart new file mode 100644 index 0000000..69de112 --- /dev/null +++ b/lib/presentation/pages/tasks/widgets/download_tasks_tab.dart @@ -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( + 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 activeTasks, + required List failedTasks, + required List 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 allTasks, + required List activeTasks, + required List failedTasks, + required List 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(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 _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 checkInstallPermission() async { + if (await Permission.requestInstallPackages.isDenied) { + await Permission.requestInstallPackages.request(); + } + } + + Future _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 _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 _confirmClear(BuildContext context, String label, int count, VoidCallback onConfirm) async { + final confirmed = await showDialog( + 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 _confirmDeleteDownloadTask( + BuildContext context, + DownloadTaskModel task, + DownloadManagerProvider downloadManager, + ) async { + final confirmed = await showDialog( + 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')}'; + } + } +} diff --git a/lib/presentation/pages/tasks/widgets/upload_tasks_tab.dart b/lib/presentation/pages/tasks/widgets/upload_tasks_tab.dart new file mode 100644 index 0000000..0b7d994 --- /dev/null +++ b/lib/presentation/pages/tasks/widgets/upload_tasks_tab.dart @@ -0,0 +1,521 @@ +import 'package:cloudreve4_flutter/data/models/upload_task_model.dart'; +import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart'; +import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart'; +import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart'; +import 'package:cloudreve4_flutter/presentation/widgets/upload_progress_item.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +class UploadTasksTab extends StatelessWidget { + const UploadTasksTab({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Consumer( + builder: (context, uploadManager, _) { + final allTasks = uploadManager.allTasks; + final activeTasks = allTasks.where((t) => + t.status == UploadStatus.uploading || t.status == UploadStatus.waiting || t.status == UploadStatus.paused).toList(); + final completedTasks = allTasks.where((t) => t.status == UploadStatus.completed).toList(); + final failedTasks = allTasks.where((t) => + t.status == UploadStatus.failed || t.status == UploadStatus.cancelled).toList(); + + if (allTasks.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.upload, size: 48, color: theme.hintColor.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text('暂无上传任务', style: TextStyle(color: theme.hintColor)), + ], + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 800; + + if (isDesktop) { + return _buildDesktopLayout( + context, + uploadManager, + allTasks: allTasks, + activeTasks: activeTasks, + failedTasks: failedTasks, + completedTasks: completedTasks, + ); + } + + return _buildMobileLayout( + context, + uploadManager, + activeTasks: activeTasks, + failedTasks: failedTasks, + completedTasks: completedTasks, + ); + }, + ); + }, + ); + } + + Widget _buildMobileLayout( + BuildContext context, + UploadManagerProvider uploadManager, { + required List activeTasks, + required List failedTasks, + required List completedTasks, + }) { + return ListView( + padding: const EdgeInsets.only(top: 8, bottom: 80), + children: [ + if (activeTasks.isNotEmpty) ...[ + _buildSectionHeader(context, '进行中', activeTasks.length), + ...activeTasks.map((task) => UploadProgressItem( + task: task, + onPause: () => uploadManager.cancelUpload(task.id), + onResume: () => uploadManager.retryUpload(task.id), + onCancel: () => uploadManager.cancelUpload(task.id), + )), + ], + if (failedTasks.isNotEmpty) ...[ + _buildSectionHeader(context, '失败', failedTasks.length, + actionLabel: '清除失败', + onAction: () => _confirmClear(context, '失败', failedTasks.length, () => uploadManager.clearFailedTasks())), + ...failedTasks.map((task) => UploadProgressItem( + task: task, + onRetry: () => uploadManager.retryUpload(task.id), + onDelete: () => _confirmDeleteUploadTask(context, task, uploadManager), + )), + ], + if (completedTasks.isNotEmpty) ...[ + _buildSectionHeader(context, '已完成', completedTasks.length, + actionLabel: '清除已完成', + onAction: () => _confirmClear(context, '已完成', completedTasks.length, () => uploadManager.clearCompletedTasks())), + ...completedTasks.map((task) => UploadProgressItem( + task: task, + onNavigate: () => _navigateToUploadedFile(context, task), + onDelete: () => _confirmDeleteUploadTask(context, task, uploadManager), + )), + ], + ], + ); + } + + Widget _buildDesktopLayout( + BuildContext context, + UploadManagerProvider uploadManager, { + required List allTasks, + required List activeTasks, + required List failedTasks, + required List completedTasks, + }) { + final colorScheme = Theme.of(context).colorScheme; + + final sortedTasks = [ + ...activeTasks.reversed, + ...failedTasks.reversed, + ...completedTasks.reversed, + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (failedTasks.isNotEmpty) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextButton.icon( + icon: const Icon(LucideIcons.trash2, size: 14), + label: const Text('清除失败', style: TextStyle(fontSize: 12)), + onPressed: () => _confirmClear(context, '失败', failedTasks.length, () => uploadManager.clearFailedTasks()), + style: TextButton.styleFrom( + foregroundColor: colorScheme.error, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ), + if (completedTasks.isNotEmpty && failedTasks.isEmpty) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextButton.icon( + icon: const Icon(LucideIcons.trash2, size: 14), + label: const Text('清除已完成', style: TextStyle(fontSize: 12)), + onPressed: () => _confirmClear(context, '已完成', completedTasks.length, () => uploadManager.clearCompletedTasks()), + style: TextButton.styleFrom( + foregroundColor: colorScheme.error, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ), + SizedBox( + width: double.infinity, + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: DataTable( + headingRowColor: WidgetStateProperty.all(colorScheme.surfaceContainerHighest), + columnSpacing: 24, + columns: const [ + DataColumn(label: Text('名称')), + DataColumn(label: Text('状态')), + DataColumn(label: Text('进度')), + DataColumn(label: Text('大小')), + DataColumn(label: Text('速度/完成时间')), + DataColumn(label: Text('操作')), + ], + rows: sortedTasks.map((task) => _buildUploadDataRow(context, task, uploadManager)).toList(), + ), + ), + ), + ], + ), + ); + } + + DataRow _buildUploadDataRow( + BuildContext context, + UploadTaskModel task, + UploadManagerProvider uploadManager, + ) { + final colorScheme = Theme.of(context).colorScheme; + final errorColor = colorScheme.error; + final statusColor = _getStatusColor(task.status); + final statusIcon = _getStatusIcon(task.status); + final isActive = task.status == UploadStatus.uploading || + task.status == UploadStatus.waiting || + task.status == UploadStatus.paused; + + return DataRow( + cells: [ + // 名称 (with status icon) + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(statusIcon, size: 18, color: statusColor), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + task.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + // 状态 + DataCell( + Text( + task.statusText, + style: TextStyle(color: statusColor, fontSize: 13), + ), + ), + // 进度 + DataCell( + isActive + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: LinearProgressIndicator( + value: task.status == UploadStatus.paused ? null : task.progress, + backgroundColor: colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation(colorScheme.primary), + ), + ), + const SizedBox(width: 8), + Text( + task.status == UploadStatus.paused ? '已暂停' : task.progressText, + style: const TextStyle(fontSize: 12), + ), + ], + ) + : Text( + task.status == UploadStatus.completed ? '100%' : '-', + style: const TextStyle(fontSize: 12), + ), + ), + // 大小 + DataCell(Text(task.readableFileSize, style: const TextStyle(fontSize: 13))), + // 速度/完成时间 + DataCell( + Text( + task.status == UploadStatus.completed + ? (task.completedAt != null ? _formatDateTime(task.completedAt!) : '-') + : (task.speedText.isNotEmpty ? task.speedText : '-'), + style: TextStyle( + fontSize: 13, + color: task.status == UploadStatus.completed + ? null + : (task.speedText.isNotEmpty ? colorScheme.primary : null), + ), + ), + ), + // 操作 + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: _buildDesktopActionButtons(context, task, uploadManager, errorColor), + ), + ), + ], + ); + } + + List _buildDesktopActionButtons( + BuildContext context, + UploadTaskModel task, + UploadManagerProvider uploadManager, + Color errorColor, + ) { + switch (task.status) { + case UploadStatus.waiting: + case UploadStatus.uploading: + return [ + IconButton( + icon: const Icon(Icons.pause, size: 18), + onPressed: () => uploadManager.cancelUpload(task.id), + tooltip: '暂停', + ), + ]; + case UploadStatus.paused: + return [ + IconButton( + icon: const Icon(Icons.play_arrow, size: 18), + onPressed: () => uploadManager.retryUpload(task.id), + tooltip: '继续', + ), + IconButton( + icon: Icon(Icons.cancel, size: 18, color: errorColor), + onPressed: () => uploadManager.cancelUpload(task.id), + tooltip: '取消', + ), + ]; + case UploadStatus.failed: + return [ + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: () => uploadManager.retryUpload(task.id), + tooltip: '重试', + ), + IconButton( + icon: Icon(Icons.delete, size: 18, color: errorColor), + onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager), + tooltip: '删除', + ), + ]; + case UploadStatus.completed: + return [ + IconButton( + icon: const Icon(LucideIcons.folderOpen, size: 18), + onPressed: () => _navigateToUploadedFile(context, task), + tooltip: '打开文件夹', + ), + IconButton( + icon: Icon(Icons.delete_outline, size: 18, color: errorColor), + onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager), + tooltip: '删除', + ), + ]; + case UploadStatus.cancelled: + return [ + IconButton( + icon: Icon(Icons.delete, size: 18, color: errorColor), + onPressed: () => _confirmDeleteUploadTask(context, task, uploadManager), + tooltip: '删除', + ), + ]; + } + } + + IconData _getStatusIcon(UploadStatus status) { + switch (status) { + case UploadStatus.waiting: + return LucideIcons.clock; + case UploadStatus.uploading: + return LucideIcons.upload; + case UploadStatus.completed: + return LucideIcons.checkCircle2; + case UploadStatus.paused: + return LucideIcons.pause; + case UploadStatus.failed: + case UploadStatus.cancelled: + return LucideIcons.xCircle; + } + } + + Color _getStatusColor(UploadStatus status) { + switch (status) { + case UploadStatus.waiting: + return Colors.orange; + case UploadStatus.uploading: + return Colors.blue; + case UploadStatus.completed: + return Colors.green; + case UploadStatus.paused: + return Colors.orange; + case UploadStatus.failed: + case UploadStatus.cancelled: + return Colors.red; + } + } + + Widget _buildSectionHeader( + BuildContext context, + String title, + int count, { + String? actionLabel, + VoidCallback? onAction, + }) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 8, 4), + child: Row( + children: [ + Text( + '$title ($count)', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.hintColor, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (actionLabel != null && onAction != null) + TextButton.icon( + icon: const Icon(LucideIcons.trash2, size: 14), + label: Text(actionLabel, style: const TextStyle(fontSize: 12)), + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ); + } + + Future _confirmClear(BuildContext context, String label, int count, VoidCallback onConfirm) async { + final confirmed = await showDialog( + 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 _confirmDeleteUploadTask( + BuildContext context, + UploadTaskModel task, + UploadManagerProvider uploadManager, + ) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除上传任务'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('确定要删除该任务吗?'), + const SizedBox(height: 8), + Text(task.fileName, style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Text('上传时间: ${_formatDateTime(task.createdAt)}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)), + Text('文件大小: ${task.readableFileSize}', style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed == true) uploadManager.removeTask(task.id); + } + + void _navigateToUploadedFile(BuildContext context, UploadTaskModel task) { + // targetPath 格式: cloudreve://my/folder + final targetPath = task.targetPath; + String relativePath; + if (targetPath.startsWith('cloudreve://my')) { + relativePath = targetPath.replaceFirst('cloudreve://my', ''); + if (relativePath.isEmpty) relativePath = '/'; + } else { + relativePath = targetPath; + } + + // 构造文件完整路径用于高亮 + final filePath = targetPath.endsWith('/') + ? '$targetPath${task.fileName}' + : '$targetPath/${task.fileName}'; + + final fileManager = Provider.of(context, listen: false); + final navProvider = Provider.of(context, listen: false); + fileManager.navigateAndHighlight(relativePath, filePath); + navProvider.setIndex(1); + } + + String _formatDateTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return '刚刚'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}分钟前'; + } else if (difference.inHours < 24) { + return '${difference.inHours}小时前'; + } else { + return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/presentation/pages/webdav/webdav_page.dart b/lib/presentation/pages/webdav/webdav_page.dart new file mode 100644 index 0000000..ac1433d --- /dev/null +++ b/lib/presentation/pages/webdav/webdav_page.dart @@ -0,0 +1,721 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../../../data/models/dav_account_model.dart'; +import '../../../data/models/server_model.dart'; +import '../../../services/webdav_service.dart'; +import '../../../services/storage_service.dart'; +import '../../providers/auth_provider.dart'; +import '../../widgets/toast_helper.dart'; + +class WebdavPage extends StatefulWidget { + const WebdavPage({super.key}); + + @override + State createState() => _WebdavPageState(); +} + +class _WebdavPageState extends State { + List _accounts = []; + bool _isLoading = false; + String? _errorMessage; + + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadAccounts(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('WebDAV'), + actions: [ + IconButton( + icon: const Icon(LucideIcons.refreshCw), + onPressed: () => _loadAccounts(), + tooltip: '刷新', + ), + ], + ), + body: Column( + children: [ + _buildSearchBar(), + Expanded(child: _buildBody()), + ], + ), + floatingActionButton: LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 800; + if (isDesktop) return const SizedBox.shrink(); + return FloatingActionButton.extended( + onPressed: () => _showCreateDialog(context), + label: const Text('添加账户'), + icon: const Icon(LucideIcons.plus), + ); + }, + ), + ); + } + + Widget _buildSearchBar() { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: SizedBox( + height: 40, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '搜索 WebDAV 账户...', + prefixIcon: const Icon(LucideIcons.search, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + isDense: true, + ), + onChanged: (value) { + _searchQuery = value.toLowerCase(); + setState(() {}); + }, + ), + ), + ); + } + + Widget _buildBody() { + final filteredAccounts = _searchQuery.isEmpty + ? _accounts + : _accounts + .where((a) => + a.name.toLowerCase().contains(_searchQuery) || + a.uri.toLowerCase().contains(_searchQuery)) + .toList(); + + if (_isLoading && _accounts.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage != null) return _buildErrorState(); + if (filteredAccounts.isEmpty) { + return _searchQuery.isEmpty ? _buildEmptyState() : _buildNoSearchResult(); + } + + return RefreshIndicator( + onRefresh: () => _loadAccounts(), + child: LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 800; + return isDesktop + ? _buildDesktopLayout(filteredAccounts) + : _buildMobileLayout(filteredAccounts); + }, + ), + ); + } + + // ─── 桌面端布局 ─── + + Widget _buildDesktopLayout(List accounts) { + final colorScheme = Theme.of(context).colorScheme; + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: DataTable( + headingRowColor: + WidgetStateProperty.all(colorScheme.surfaceContainerHighest), + columnSpacing: 24, + columns: const [ + DataColumn(label: Text('名称', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('URI', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('密码', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('创建时间', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('操作', style: TextStyle(fontWeight: FontWeight.bold))), + ], + rows: accounts.map((account) { + return DataRow( + cells: [ + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Row( + children: [ + _buildAccountIcon(colorScheme, size: 18), + const SizedBox(width: 12), + Expanded(child: Text(account.name, overflow: TextOverflow.ellipsis)), + ], + ), + ), + ), + DataCell(Text(account.uri, style: const TextStyle(fontSize: 12))), + DataCell(Text(_maskPassword(account.password), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), + DataCell(Text(_formatDate(account.createdAt), style: const TextStyle(fontSize: 12))), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildActionButton( + icon: LucideIcons.copy, + tooltip: '复制凭据', + onPressed: () => _copyCredentials(context, account), + ), + _buildActionButton( + icon: LucideIcons.pencil, + tooltip: '编辑', + onPressed: () => _showEditDialog(context, account), + ), + _buildActionButton( + icon: LucideIcons.trash2, + tooltip: '删除', + color: colorScheme.error, + onPressed: () => _showDeleteDialog(context, account), + ), + ], + ), + ), + ], + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _showCreateDialog(context), + icon: const Icon(LucideIcons.plus, size: 18), + label: const Text('添加账户'), + ), + ], + ), + ); + } + + // ─── 移动端布局 ─── + + Widget _buildMobileLayout(List accounts) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: accounts.length, + itemBuilder: (context, index) { + final account = accounts[index]; + return InkWell( + onTap: () => _showMobileActionMenu(account), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + children: [ + _buildAccountIcon(colorScheme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '${account.uri} · ${_maskPassword(account.password)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(LucideIcons.moreVertical, size: 18), + onPressed: () => _showMobileActionMenu(account), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(8), + minimumSize: const Size(36, 36), + ), + ), + ], + ), + ), + ); + }, + ); + } + + // ─── 账户图标 ─── + + Widget _buildAccountIcon(ColorScheme colorScheme, {double size = 18}) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(LucideIcons.cloud, color: colorScheme.onPrimaryContainer, size: size), + ); + } + + // ─── 桌面端操作按钮 ─── + + Widget _buildActionButton({ + required IconData icon, + required String tooltip, + Color? color, + required VoidCallback onPressed, + }) { + return IconButton( + icon: Icon(icon, size: 18, color: color), + onPressed: onPressed, + tooltip: tooltip, + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(32, 32), + ), + ); + } + + String _maskPassword(String password) { + if (password.length <= 4) return '••••'; + return '••••${password.substring(password.length - 4)}'; + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inDays == 0) return '今天'; + if (diff.inDays == 1) return '昨天'; + if (diff.inDays < 7) return '${diff.inDays} 天前'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + // ─── 移动端菜单 ─── + + void _showMobileActionMenu(DavAccountModel account) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('复制凭据'), + onTap: () { + Navigator.pop(context); + _copyCredentials(this.context, account); + }, + ), + ListTile( + leading: const Icon(LucideIcons.pencil), + title: const Text('编辑账户'), + onTap: () { + Navigator.pop(context); + _showEditDialog(this.context, account); + }, + ), + ListTile( + leading: Icon(LucideIcons.trash2, color: colorScheme.error), + title: Text('删除账户', style: TextStyle(color: colorScheme.error)), + onTap: () { + Navigator.pop(context); + _showDeleteDialog(this.context, account); + }, + ), + ], + ), + ), + ); + } + + // ─── 空状态 / 错误状态 ─── + + Widget _buildEmptyState() { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.cloud, size: 48, color: theme.colorScheme.outline), + const SizedBox(height: 16), + Text('暂无 WebDAV 账户', style: TextStyle(color: theme.hintColor)), + const SizedBox(height: 8), + Text('点击下方按钮添加账户', + style: TextStyle(fontSize: 12, color: theme.hintColor)), + ], + ), + ); + } + + Widget _buildNoSearchResult() { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.searchX, size: 48, color: theme.colorScheme.outline), + const SizedBox(height: 16), + Text('没有找到 "$_searchQuery"', style: TextStyle(color: theme.hintColor)), + ], + ), + ); + } + + Widget _buildErrorState() { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('加载失败', style: TextStyle(color: theme.hintColor)), + const SizedBox(height: 8), + Text(_errorMessage ?? '未知错误', + style: TextStyle(fontSize: 12, color: theme.hintColor)), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(LucideIcons.refreshCw, size: 18), + label: const Text('重试'), + onPressed: _loadAccounts, + ), + ], + ), + ); + } + + // ─── 数据操作 ─── + + Future _loadAccounts() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await WebdavService().listAccounts(pageSize: 50); + final accountsData = response as Map?; + final accountsList = accountsData?['accounts'] as List? ?? []; + final accounts = accountsList + .map((a) => DavAccountModel.fromJson(a as Map)) + .toList(); + + if (!mounted) return; + setState(() { + _accounts = accounts; + _isLoading = false; + }); + ToastHelper.success('刷新成功'); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + ToastHelper.failure('刷新失败: $e'); + } + } + + void _copyCredentials(BuildContext context, DavAccountModel account) async { + final authProvider = Provider.of(context, listen: false); + final username = authProvider.user?.email ?? account.id; + + final storageService = StorageService.instance; + final servers = await storageService.servers; + final lastLabel = await storageService.lastSelectedServerLabel; + + String davUrl = account.uri; + if (lastLabel != null) { + final currentServer = servers.firstWhere( + (s) => s.label == lastLabel, + orElse: () => ServerModel(label: '', baseUrl: ''), + ); + if (currentServer.baseUrl.isNotEmpty) { + final cleanBaseUrl = currentServer.baseUrl.replaceAll(RegExp(r'/api/v4/?$'), ''); + davUrl = '$cleanBaseUrl/dav'; + } + } + + final credentials = '地址: $davUrl\n用户: $username\n密码: ${account.password}'; + Clipboard.setData(ClipboardData(text: credentials)); + if (mounted) ToastHelper.success('凭据已复制到剪贴板'); + } + + Future _showCreateDialog(BuildContext context) async { + final nameController = TextEditingController(); + final uriController = TextEditingController(); + final proxyController = TextEditingController(text: 'false'); + final readonlyController = TextEditingController(text: 'false'); + + final created = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('添加 WebDAV 账户'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '名称', + hintText: '请输入备注名称', + prefixIcon: Icon(LucideIcons.tag), + ), + ), + const SizedBox(height: 16), + TextField( + controller: uriController, + decoration: const InputDecoration( + labelText: 'URI', + hintText: '/ or /folder', + prefixIcon: Icon(LucideIcons.link), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: proxyController, + decoration: const InputDecoration( + labelText: '反向代理', + hintText: 'true/false', + prefixIcon: Icon(LucideIcons.arrowLeftRight), + ), + keyboardType: TextInputType.text, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: readonlyController, + decoration: const InputDecoration( + labelText: '只读', + hintText: 'true/false', + prefixIcon: Icon(LucideIcons.eyeOff), + ), + keyboardType: TextInputType.text, + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('创建'), + ), + ], + ), + ); + + if (!mounted) return; + if (created == true) { + final name = nameController.text.trim(); + String uri = uriController.text.trim(); + final proxy = proxyController.text.trim().toLowerCase() == 'true'; + final readonly = readonlyController.text.trim().toLowerCase() == 'true'; + + if (name.isEmpty || uri.isEmpty) { + ToastHelper.error('请填写名称和URI'); + return; + } + + setState(() => _isLoading = true); + try { + final account = await WebdavService().createAccount( + uri: 'cloudreve://my$uri', + name: name, + proxy: proxy, + readonly: readonly, + ); + if (!mounted) return; + setState(() { + _accounts.add(account); + _isLoading = false; + }); + ToastHelper.success('创建成功'); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + ToastHelper.failure('创建失败: $e'); + } + } + } + + Future _showEditDialog(BuildContext context, DavAccountModel account) async { + final nameController = TextEditingController(text: account.name); + final uriController = TextEditingController(text: account.uri); + + final updated = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('编辑 WebDAV 账户'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '名称', + hintText: '请输入备注名称', + prefixIcon: Icon(LucideIcons.tag), + ), + ), + const SizedBox(height: 16), + TextField( + controller: uriController, + decoration: const InputDecoration( + labelText: 'URI', + hintText: 'cloudreve://my', + prefixIcon: Icon(LucideIcons.link), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('保存'), + ), + ], + ), + ); + + if (!mounted) return; + if (updated == true) { + final name = nameController.text.trim(); + final uri = uriController.text.trim(); + if (name.isEmpty || uri.isEmpty) { + ToastHelper.error('请填写名称和URI'); + return; + } + + setState(() => _isLoading = true); + try { + await WebdavService().updateAccount(id: account.id, name: name, uri: uri); + if (!mounted) return; + setState(() { + final index = _accounts.indexWhere((a) => a.id == account.id); + if (index != -1) { + _accounts[index] = account.copyWith(name: name, uri: uri); + } + _isLoading = false; + }); + ToastHelper.success('更新成功'); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + ToastHelper.failure('更新失败: $e'); + } + } + } + + Future _showDeleteDialog(BuildContext context, DavAccountModel account) async { + final colorScheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除账户'), + content: Text('确定要删除 WebDAV 账户 "${account.name}" 吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom(backgroundColor: colorScheme.error), + child: const Text('删除'), + ), + ], + ), + ); + + if (!mounted) return; + if (confirmed == true) { + setState(() => _isLoading = true); + try { + await WebdavService().deleteAccount(account.id); + if (!mounted) return; + setState(() { + _accounts.removeWhere((a) => a.id == account.id); + _isLoading = false; + }); + ToastHelper.success('删除成功'); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + ToastHelper.failure('删除失败: $e'); + } + } + } +} + +extension DavAccountModelExtension on DavAccountModel { + DavAccountModel copyWith({ + String? name, + String? uri, + DateTime? createdAt, + String? password, + String? options, + }) { + return DavAccountModel( + id: id, + createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + uri: uri ?? this.uri, + password: password ?? this.password, + options: options ?? this.options, + ); + } +} diff --git a/lib/presentation/providers/admin_provider.dart b/lib/presentation/providers/admin_provider.dart new file mode 100644 index 0000000..41879ff --- /dev/null +++ b/lib/presentation/providers/admin_provider.dart @@ -0,0 +1,220 @@ +import 'package:flutter/foundation.dart'; +import '../../data/models/admin_model.dart'; +import '../../services/admin_service.dart'; +import '../../core/utils/app_logger.dart'; + +enum AdminState { idle, loading, error } + +/// 管理员数据 Provider +class AdminProvider extends ChangeNotifier { + AdminState _state = AdminState.idle; + List _groups = []; + List _users = []; + PaginationModel? _groupsPagination; + PaginationModel? _usersPagination; + String? _errorMessage; + + // 用户多选状态 + final Set _selectedUserIds = {}; + bool _isSelectingUsers = false; + + AdminState get state => _state; + List get groups => _groups; + List get users => _users; + PaginationModel? get groupsPagination => _groupsPagination; + PaginationModel? get usersPagination => _usersPagination; + String? get errorMessage => _errorMessage; + bool get isLoading => _state == AdminState.loading; + Set get selectedUserIds => _selectedUserIds; + bool get isSelectingUsers => _isSelectingUsers; + bool get hasSelectedUsers => _selectedUserIds.isNotEmpty; + + final AdminService _service = AdminService.instance; + + /// 加载用户组列表 + Future loadGroups({int page = 1}) async { + try { + final response = await _service.getGroups(page: page); + _groups = response.groups; + _groupsPagination = response.pagination; + notifyListeners(); + } catch (e) { + AppLogger.d('加载用户组失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + } + } + + /// 加载用户列表 + Future loadUsers({int page = 1}) async { + try { + final response = await _service.getUsers(page: page); + _users = response.users; + _usersPagination = response.pagination; + notifyListeners(); + } catch (e) { + AppLogger.d('加载用户列表失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + } + } + + /// 加载全部管理员数据 + Future loadAll() async { + _setState(AdminState.loading); + try { + await Future.wait([ + loadGroups(), + loadUsers(), + ]); + _setState(AdminState.idle); + } catch (e) { + _errorMessage = e.toString(); + _setState(AdminState.error); + } + } + + /// 创建用户组 + Future createGroup(String name) async { + try { + final group = await _service.createGroup(name); + _groups.insert(0, group); + if (_groupsPagination != null) { + _groupsPagination = PaginationModel( + page: _groupsPagination!.page, + pageSize: _groupsPagination!.pageSize, + totalItems: _groupsPagination!.totalItems + 1, + ); + } + notifyListeners(); + return true; + } catch (e) { + AppLogger.d('创建用户组失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 删除用户组(会先检查组下是否有用户) + Future deleteGroup(int groupId) async { + try { + final detail = await _service.getGroupDetail(groupId); + if ((detail.totalUsers ?? 0) > 0) { + return '该组下有 ${detail.totalUsers} 个用户,请先删除或迁移用户'; + } + await _service.deleteGroup(groupId); + _groups.removeWhere((g) => g.id == groupId); + if (_groupsPagination != null) { + _groupsPagination = PaginationModel( + page: _groupsPagination!.page, + pageSize: _groupsPagination!.pageSize, + totalItems: _groupsPagination!.totalItems - 1, + ); + } + notifyListeners(); + return null; + } catch (e) { + AppLogger.d('删除用户组失败: $e'); + return e.toString(); + } + } + + /// 创建用户 + Future createUser({ + required String email, + required String nick, + required String password, + required int groupId, + }) async { + try { + final user = await _service.createUser( + email: email, + nick: nick, + password: password, + groupId: groupId, + ); + _users.insert(0, user); + if (_usersPagination != null) { + _usersPagination = PaginationModel( + page: _usersPagination!.page, + pageSize: _usersPagination!.pageSize, + totalItems: _usersPagination!.totalItems + 1, + ); + } + notifyListeners(); + return true; + } catch (e) { + AppLogger.d('创建用户失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 批量删除用户 + Future batchDeleteUsers(List ids) async { + try { + await _service.batchDeleteUsers(ids); + _users.removeWhere((u) => ids.contains(u.id)); + if (_usersPagination != null) { + _usersPagination = PaginationModel( + page: _usersPagination!.page, + pageSize: _usersPagination!.pageSize, + totalItems: _usersPagination!.totalItems - ids.length, + ); + } + exitSelectMode(); + notifyListeners(); + return true; + } catch (e) { + AppLogger.d('批量删除用户失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + // --- 多选模式 --- + + void toggleSelectMode() { + _isSelectingUsers = !_isSelectingUsers; + if (!_isSelectingUsers) { + _selectedUserIds.clear(); + } + notifyListeners(); + } + + void exitSelectMode() { + _isSelectingUsers = false; + _selectedUserIds.clear(); + notifyListeners(); + } + + void toggleUserSelection(int userId) { + if (_selectedUserIds.contains(userId)) { + _selectedUserIds.remove(userId); + } else { + _selectedUserIds.add(userId); + } + notifyListeners(); + } + + void selectAllUsers() { + _selectedUserIds.clear(); + _selectedUserIds.addAll(_users.map((u) => u.id)); + notifyListeners(); + } + + void clearUserSelection() { + _selectedUserIds.clear(); + notifyListeners(); + } + + bool isUserSelected(int userId) => _selectedUserIds.contains(userId); + + void _setState(AdminState state) { + _state = state; + notifyListeners(); + } +} diff --git a/lib/presentation/providers/auth_provider.dart b/lib/presentation/providers/auth_provider.dart new file mode 100644 index 0000000..93061fd --- /dev/null +++ b/lib/presentation/providers/auth_provider.dart @@ -0,0 +1,333 @@ +import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart'; +import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart'; +import 'package:flutter/foundation.dart'; +import '../../data/models/server_model.dart'; +import '../../data/models/user_model.dart'; +import '../../services/auth_service.dart'; +import '../../services/server_service.dart'; +import '../../services/storage_service.dart'; +import '../../services/api_service.dart'; +import '../../core/exceptions/app_exception.dart'; +import '../../core/utils/app_logger.dart'; + +/// 认证状态 +enum AuthState { loading, authenticated, unauthenticated, error } + +/// 认证Provider +class AuthProvider extends ChangeNotifier { + AuthState _state = AuthState.unauthenticated; + UserModel? _user; + String? _errorMessage; + bool _hasRefreshTokenExpired = false; + + AuthState get state => _state; + UserModel? get user => _user; + String? get errorMessage => _errorMessage; + bool get isAuthenticated => _state == AuthState.authenticated; + bool get isLoading => _state == AuthState.loading; + bool get hasRefreshTokenExpired => _hasRefreshTokenExpired; + bool get isAdmin { + final name = _user?.group?.name.toLowerCase(); + return name == 'admin' || name == '管理员'; + } + + /// 当前选中的服务器 + ServerModel? get currentServer => ServerService.instance.currentServer; + + /// 获取当前用户的 token + TokenModel? get token => _user?.token; + + /// 初始化 + Future init() async { + try { + setState(AuthState.loading); + + // 初始化服务器服务 + await ServerService.instance.init(); + + // 获取当前服务器 + final server = ServerService.instance.currentServer; + if (server == null) { + setState(AuthState.unauthenticated); + return; + } + + // 设置 API 的 baseUrl + await _setApiBaseUrl(server.baseUrl); + + // 设置 ApiService 的认证回调 + _setupApiCallbacks(); + + // 检查是否有保存的登录信息 + if (server.user != null && server.user!.token != null) { + // 有保存的用户信息,检查 token 是否过期 + if (!server.user!.token!.isRefreshTokenExpired) { + // Refresh token 未过期,设置用户信息 + setUser(server.user); + setState(AuthState.authenticated); + return; + } else { + // Refresh token 已过期,清除登录信息 + await ServerService.instance.clearCurrentServerLogin(); + } + } + + _user = null; + setState(AuthState.unauthenticated); + } catch (e) { + AppLogger.d('AuthProvider 初始化失败: $e'); + _user = null; + setState(AuthState.unauthenticated); + } + } + + /// 设置 ApiService 的认证回调 + void _setupApiCallbacks() { + ApiService.setAuthCallbacks( + getToken: () async { + // 返回当前的 access token + return _user?.token?.accessToken; + }, + refreshToken: () async { + // 刷新 token + try { + await refreshToken(); + } catch (e) { + // 刷新失败,设置过期标志 + if (e is RefreshTokenExpiredException) { + setRefreshTokenExpired(); + } + rethrow; + } + }, + clearAuth: () async { + // 清除认证数据 + await ServerService.instance.clearCurrentServerLogin(); + setRefreshTokenExpired(); + }, + ); + } + + /// 设置 API baseUrl + Future _setApiBaseUrl(String baseUrl) async { + // 同时更新存储和 ApiService 的 baseUrl + final storageService = StorageService.instance; + await storageService.setCustomBaseUrl(baseUrl); + await ApiService.instance.setBaseUrl(baseUrl); + } + + /// 密码登录 + Future passwordLogin({ + required String email, + required String password, + bool rememberMe = false, + }) async { + try { + setState(AuthState.loading); + + // 获取当前服务器 + final server = ServerService.instance.currentServer; + if (server == null) { + _errorMessage = '服务器初始化失败'; + _user = null; + setState(AuthState.error); + return false; + } + + // 每次登录时都重新设置 API 的 baseUrl,确保使用最新的服务器地址 + await _setApiBaseUrl(server.baseUrl); + + // 执行登录 + final response = await AuthService.instance.passwordLogin( + email: email, + password: password, + ); + AppLogger.d('AuthProvider 登录成功: $response'); + // 保存登录信息到当前服务器(包含完整 user 和 token) + await ServerService.instance.updateCurrentServerLogin( + email: rememberMe ? email : null, + password: rememberMe ? password : null, + user: response.user, + rememberMe: rememberMe, + ); + + setUser(response.user); + setState(AuthState.authenticated); + + return true; + } on TwoFactorRequiredException { + // 两步验证需要,不设置 error 状态,重新抛出让调用方处理 + setState(AuthState.unauthenticated); + rethrow; + } catch (e) { + _errorMessage = e.toString(); + _user = null; + setState(AuthState.error); + return false; + } + } + + /// 两步验证登录 + Future twoFactorLogin({ + required String otp, + required String sessionId, + required String email, + required String password, + bool rememberMe = false, + }) async { + try { + setState(AuthState.loading); + + final response = await AuthService.instance.twoFactorLogin( + otp: otp, + sessionId: sessionId, + ); + + await ServerService.instance.updateCurrentServerLogin( + email: rememberMe ? email : null, + password: rememberMe ? password : null, + user: response.user, + rememberMe: rememberMe, + ); + + setUser(response.user); + setState(AuthState.authenticated); + return true; + } catch (e) { + _errorMessage = e.toString(); + _user = null; + setState(AuthState.error); + return false; + } + } + + /// 登出 + Future logout() async { + try { + // 调用登出 API(需要 token) + if (token?.refreshToken != null) { + try { + await AuthService.instance.logout(); + } catch (e) { + // 登出 API 调用失败不影响本地清理 + AppLogger.d('登出 API 调用失败: $e'); + } + } + + // 清除当前服务器的登录信息 + await ServerService.instance.clearCurrentServerLogin(); + + _clearUserData(); + // 清除头像缓存 + await UserAvatar.clearAllCache(); + setState(AuthState.unauthenticated); + } catch (e) { + // 即使出错也要清除本地状态 + _clearUserData(); + setState(AuthState.unauthenticated); + _errorMessage = e.toString(); + } + } + + /// 刷新用户信息 + Future refreshUser() async { + try { + final user = await AuthService.instance.getCurrentUser(); + setUser(user); + + // 更新服务器中的用户信息(保留 token) + final server = ServerService.instance.currentServer; + if (server != null && token != null) { + await ServerService.instance.updateCurrentServerLogin( + user: user.copyWith(token: token), + ); + } + } catch (e) { + _errorMessage = e.toString(); + } + } + + /// 刷新 token + Future refreshToken() async { + try { + final currentToken = token; + if (currentToken == null || currentToken.isRefreshTokenExpired) { + ToastHelper.failure('已注销或Refresh token 已过期,需要重新登录'); + throw Exception('Refresh token 已过期,需要重新登录'); + } + + // 调用刷新 token 接口 + final newToken = await AuthService.instance.refreshToken(currentToken.refreshToken); + + // 更新用户信息中的 token + final updatedUser = _user!.copyWith(token: newToken); + + // 保存到服务器 + await ServerService.instance.updateCurrentServerLogin(user: updatedUser); + + setUser(updatedUser); + } catch (e) { + ToastHelper.error('刷新 token 失败: $e'); + AppLogger.d('刷新 token 失败: $e'); + _user = null; + _errorMessage = e.toString(); + notifyListeners(); + rethrow; + } + } + + /// 设置用户 + void setUser(UserModel? user) { + _user = user; + notifyListeners(); + } + + /// 设置状态 + void setState(AuthState state) { + _state = state; + notifyListeners(); + } + + /// 清除错误 + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + /// 清除用户数据 + void _clearUserData() { + _user = null; + _errorMessage = null; + _hasRefreshTokenExpired = false; + notifyListeners(); + } + + /// 处理 RefreshTokenExpiredException + void setRefreshTokenExpired() { + _hasRefreshTokenExpired = true; + notifyListeners(); + } + + void clearRefreshTokenExpired() { + _hasRefreshTokenExpired = false; + notifyListeners(); + } + + /// 获取上次登录的邮箱(用于填充输入框) + String? get rememberedEmail { + final server = ServerService.instance.currentServer; + return server?.email; + } + + /// 获取上次登录的密码(用于填充输入框) + String? get rememberedPassword { + final server = ServerService.instance.currentServer; + return server?.password; + } + + /// 是否记住密码 + bool get rememberMe { + final server = ServerService.instance.currentServer; + return server?.rememberMe ?? false; + } +} diff --git a/lib/presentation/providers/download_manager_provider.dart b/lib/presentation/providers/download_manager_provider.dart new file mode 100644 index 0000000..0d529fb --- /dev/null +++ b/lib/presentation/providers/download_manager_provider.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import '../../core/constants/storage_keys.dart'; +import '../../data/models/download_task_model.dart'; +import '../../services/download_service.dart'; +import '../../services/storage_service.dart'; +import '../../core/utils/app_logger.dart'; + +/// 下载管理Provider +class DownloadManagerProvider extends ChangeNotifier { + final DownloadService _downloadService = DownloadService(); + final Map _tasks = {}; + bool _isInitialized = false; + bool _isWifiOnlyEnabled = false; + + // 速度追踪:记录每个任务的上次进度更新时间和字节数 + final Map _lastProgressTime = {}; + final Map _lastProgressBytes = {}; + + /// 获取所有下载任务 + List get tasks => _tasks.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + /// 获取指定状态的任务 + List getTasksByStatus(DownloadStatus status) { + return tasks.where((task) => task.status == status).toList(); + } + + /// 下载中的任务数 + int get downloadingCount => + getTasksByStatus(DownloadStatus.downloading).length; + + /// 活跃任务数(下载中 + 等待中 + 暂停) + int get activeTaskCount => tasks + .where((t) => + t.status == DownloadStatus.downloading || + t.status == DownloadStatus.waiting || + t.status == DownloadStatus.paused) + .length; + + /// WiFi-only 设置 + bool get isWifiOnlyEnabled => _isWifiOnlyEnabled; + + /// 初始化下载服务 + Future initialize() async { + if (_isInitialized) return; + + await _downloadService.initialize(callbackHandler: _handleDownloadCallback); + + // 加载 WiFi-only 设置 + _isWifiOnlyEnabled = + await StorageService.instance.getBool(StorageKeys.downloadWifiOnly) ?? + false; + + // 从本地存储加载已保存的下载任务 + await _loadTasks(); + + _isInitialized = true; + AppLogger.d('DownloadManagerProvider 初始化完成'); + } + + /// 更新 WiFi-only 设置,并同步等待中的任务 + Future setWifiOnlyEnabled(bool value) async { + _isWifiOnlyEnabled = value; + await StorageService.instance + .setBool(StorageKeys.downloadWifiOnly, value); + + // 如果关闭了WiFi-only,需要将等待WiFi的任务重新入队 + if (!value) { + for (final task in _tasks.values.toList()) { + if (task.waitingForWifi) { + // 取消当前等待WiFi的任务,重新入队(不需要WiFi) + await _downloadService.cancelDownload(task.id); + _tasks[task.id] = task.copyWith( + status: DownloadStatus.waiting, + waitingForWifi: false, + ); + await _saveTasks(); + // 重新开始下载 + await _downloadService.startDownload(_tasks[task.id]!); + } + } + } + + notifyListeners(); + } + + /// 添加下载任务 + Future addDownloadTask({ + required String fileName, + required String fileUri, + required int fileSize, + String? savePath, + }) async { + // 如果已存在相同文件的任务,返回null + DownloadTaskModel? existingTask; + for (final task in _tasks.values) { + if (task.fileUri == fileUri) { + existingTask = task; + break; + } + } + + if (existingTask != null) { + return null; + } + + // 确保下载服务已初始化 + await initialize(); + + // 获取保存路径 + if (savePath == null) { + final dir = await _downloadService.getDownloadDirectory(); + savePath = '${dir.path}/$fileName'; + } + + // 创建任务ID + final id = DateTime.now().millisecondsSinceEpoch.toString(); + + final task = DownloadTaskModel( + id: id, + fileName: fileName, + fileUri: fileUri, + fileSize: fileSize, + savePath: savePath, + status: DownloadStatus.waiting, + ); + + _tasks[id] = task; + await _saveTasks(); + notifyListeners(); + + // 开始下载 + AppLogger.d( + '准备开始下载任务: ${task.id}, 文件: ${task.fileName}, 下载状态: ${task.status}'); + final bdTaskId = await _downloadService.startDownload(task); + AppLogger.d('startDownload 返回: bdTaskId=$bdTaskId'); + + if (bdTaskId == null) { + // 下载失败,更新任务状态 + _tasks[id] = task.copyWith( + status: DownloadStatus.failed, + errorMessage: '无法创建下载任务', + ); + notifyListeners(); + return null; + } + + return task; + } + + /// 批量添加下载任务 + Future addBatchDownloadTasks(List> files) async { + await initialize(); + final dir = await _downloadService.getDownloadDirectory(); + + for (final file in files) { + final fileName = file['name'] as String; + final fileUri = file['path'] as String; + final fileSize = file['size'] as int? ?? 0; + + await addDownloadTask( + fileName: fileName, + fileUri: fileUri, + fileSize: fileSize, + savePath: '${dir.path}/$fileName', + ); + } + } + + /// 处理下载回调 + void _handleDownloadCallback( + String taskId, DownloadStatus status, int progress) async { + AppLogger.d( + 'DownloadManagerProvider._handleDownloadCallback: taskId=$taskId, status=$status, progress=$progress'); + + // 获取当前任务 + final task = _tasks[taskId]; + if (task == null) { + AppLogger.d('任务不存在: taskId=$taskId'); + return; + } + + // 计算下载字节数和速度 + final downloadedBytes = (task.fileSize * progress / 100).toInt(); + int speed = 0; + final now = DateTime.now(); + final lastTime = _lastProgressTime[taskId]; + final lastBytes = _lastProgressBytes[taskId] ?? 0; + + if (status == DownloadStatus.downloading && lastTime != null) { + final elapsed = now.difference(lastTime).inMilliseconds; + if (elapsed > 0) { + speed = ((downloadedBytes - lastBytes) * 1000 / elapsed).round(); + } + } + + if (status == DownloadStatus.downloading) { + _lastProgressTime[taskId] = now; + _lastProgressBytes[taskId] = downloadedBytes; + } else { + _lastProgressTime.remove(taskId); + _lastProgressBytes.remove(taskId); + } + + // 判断是否在等待WiFi + final waitingForWifi = + status == DownloadStatus.waiting && _isWifiOnlyEnabled; + + // 更新任务 + final updatedTask = task.copyWith( + status: status, + downloadedBytes: downloadedBytes, + speed: speed, + waitingForWifi: waitingForWifi, + ); + + // 如果下载完成,设置完成时间 + if (status == DownloadStatus.completed) { + _tasks[taskId] = updatedTask.copyWith( + completedAt: DateTime.now(), + speed: 0, + ); + } else { + _tasks[taskId] = updatedTask; + } + + AppLogger.d('任务已更新: ${_tasks[taskId]!.status}'); + await _saveTasks(); + notifyListeners(); + } + + /// 恢复下载 + Future resumeDownload(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + await _downloadService.resumeDownload(taskId); + } + } + + /// 暂停下载 + Future pauseDownload(String taskId) async { + await _downloadService.pauseDownload(taskId); + + final task = _tasks[taskId]; + if (task != null) { + if (task.status == DownloadStatus.downloading) { + _tasks[taskId] = task.copyWith( + status: DownloadStatus.paused, speed: 0, waitingForWifi: false); + _lastProgressTime.remove(taskId); + _lastProgressBytes.remove(taskId); + await _saveTasks(); + notifyListeners(); + } + } + } + + /// 取消下载 + Future cancelDownload(String taskId) async { + await _downloadService.cancelDownload(taskId); + + final task = _tasks[taskId]; + if (task != null) { + _tasks[taskId] = task.copyWith( + status: DownloadStatus.cancelled, waitingForWifi: false); + await _saveTasks(); + notifyListeners(); + + // 延迟移除任务,同时从存储中删除 + Future.delayed(const Duration(seconds: 2), () { + _tasks.remove(taskId); + _downloadService.disposeTask(taskId); + _saveTasks(); + notifyListeners(); + }); + } + } + + /// 删除下载任务(包括文件) + Future deleteDownloadTask(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + // 删除已下载的文件 + if (task.status == DownloadStatus.completed) { + await _downloadService.deleteDownloadedFile(task.savePath); + } + + // 移除任务 + _tasks.remove(taskId); + _downloadService.disposeTask(taskId); + await _saveTasks(); + notifyListeners(); + } + } + + /// 重新下载 + Future retryDownload(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + // 删除已下载的部分文件 + await _downloadService.deleteDownloadedFile(task.savePath); + + // 重置任务状态 + _tasks[taskId] = task.copyWith( + downloadedBytes: 0, + speed: 0, + status: DownloadStatus.waiting, + errorMessage: null, + completedAt: null, + waitingForWifi: false, + ); + _lastProgressTime.remove(taskId); + _lastProgressBytes.remove(taskId); + await _saveTasks(); + notifyListeners(); + + // 重新开始下载 + await _downloadService.startDownload(_tasks[taskId]!); + } + } + + /// 清空所有已完成的任务 + Future clearCompletedTasks() async { + final completedTasks = getTasksByStatus(DownloadStatus.completed); + for (final task in completedTasks) { + await deleteDownloadTask(task.id); + } + } + + /// 清空所有失败的任务 + Future clearFailedTasks() async { + final failedTasks = getTasksByStatus(DownloadStatus.failed); + for (final task in failedTasks) { + _tasks.remove(task.id); + _downloadService.disposeTask(task.id); + } + await _saveTasks(); + notifyListeners(); + } + + /// 获取任务 + DownloadTaskModel? getTask(String taskId) { + return _tasks[taskId]; + } + + /// 从本地存储加载下载任务 + Future _loadTasks() async { + try { + final tasksJson = + await StorageService.instance.getString(StorageKeys.downloadTasks); + if (tasksJson == null || tasksJson.isEmpty) { + AppLogger.d('没有保存的下载任务'); + return; + } + + final tasksList = jsonDecode(tasksJson) as List; + final loadedTasks = []; + + final now = DateTime.now(); + for (final taskJson in tasksList) { + try { + final task = + DownloadTaskModel.fromJson(taskJson as Map); + // 过滤掉已取消的任务(修复4:已取消任务不恢复) + if (task.status == DownloadStatus.cancelled) { + continue; + } + + // 如果任务已完成,只保留配置天数内的记录 + if (task.status == DownloadStatus.completed) { + if (task.completedAt == null) { + continue; + } + final retentionDays = await StorageService.instance + .getInt(StorageKeys.taskRetentionDays) ?? + 7; + // retentionDays == -1 表示永不过期 + if (retentionDays > 0) { + final daysSinceCompletion = + now.difference(task.completedAt!).inDays; + if (daysSinceCompletion > retentionDays) { + AppLogger.d( + '跳过超过$retentionDays天的已完成任务: ${task.fileName}'); + continue; + } + } + } + + loadedTasks.add(task); + } catch (e) { + AppLogger.d('解析下载任务失败: $e'); + } + } + + // 将加载的任务添加到当前任务列表 + for (final task in loadedTasks) { + _tasks[task.id] = task; + } + + AppLogger.d('从存储加载了 ${loadedTasks.length} 个下载任务'); + + // 通知 UI 更新 + if (loadedTasks.isNotEmpty) { + notifyListeners(); + } + + // 恢复未完成的任务 + for (final task in loadedTasks) { + if (task.status == DownloadStatus.downloading || + task.status == DownloadStatus.waiting) { + AppLogger.d('恢复下载任务: ${task.fileName}'); + // 使用 resumeDownloadAfterRestart 支持断点续传 + await _downloadService.resumeDownloadAfterRestart(task); + } else if (task.status == DownloadStatus.paused) { + // 修复5:暂停的任务需要重建 bdTasks 映射,以便继续下载 + AppLogger.d('重建暂停任务映射: ${task.fileName}'); + await _downloadService.resumeDownloadAfterRestart(task); + // 重建映射后立即暂停,保持任务在暂停状态 + await _downloadService.pauseDownload(task.id); + } + } + } catch (e) { + AppLogger.d('加载下载任务失败: $e'); + } + } + + /// 保存下载任务到本地存储 + Future _saveTasks() async { + try { + final tasksList = _tasks.values.map((task) => task.toJson()).toList(); + final tasksJson = jsonEncode(tasksList); + await StorageService.instance + .setString(StorageKeys.downloadTasks, tasksJson); + AppLogger.d('已保存 ${_tasks.length} 个下载任务到存储'); + } catch (e) { + AppLogger.d('保存下载任务失败: $e'); + } + } + + @override + void dispose() { + _downloadService.dispose(); + super.dispose(); + } +} diff --git a/lib/presentation/providers/file_manager_provider.dart b/lib/presentation/providers/file_manager_provider.dart new file mode 100644 index 0000000..d34410b --- /dev/null +++ b/lib/presentation/providers/file_manager_provider.dart @@ -0,0 +1,410 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import '../../data/models/file_model.dart'; +import '../../services/file_service.dart'; +import '../../services/thumbnail_service.dart'; +import '../../core/utils/app_logger.dart'; +import '../../core/utils/file_utils.dart'; + +/// 文件视图类型 +enum FileViewType { list, grid, gallery } + +/// 刷新结果 +class RefreshResult { + final int added; + final int removed; + final int updated; + const RefreshResult({required this.added, required this.removed, required this.updated}); + bool get isUnchanged => added == 0 && removed == 0 && updated == 0; +} + +/// 文件管理Provider +class FileManagerProvider extends ChangeNotifier { + String _currentPath = '/'; + List _files = []; + List _selectedFiles = []; + FileViewType _viewType = FileViewType.list; + bool _isLoading = false; + bool _hasMore = true; + String? _errorMessage; + String? _contextHint; + String? _highlightPath; + Timer? _highlightTimer; + + String get currentPath => _currentPath; + List get files => _files; + List get selectedFiles => _selectedFiles; + FileViewType get viewType => _viewType; + bool get isLoading => _isLoading; + bool get hasMore => _hasMore; + String? get errorMessage => _errorMessage; + String? get contextHint => _contextHint; + bool get hasSelection => _selectedFiles.isNotEmpty; + String? get highlightPath => _highlightPath; + + /// 加载文件列表 + Future loadFiles({bool refresh = false, Duration timeout = const Duration(seconds: 5)}) async { + if (refresh) { + _selectedFiles.clear(); + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await FileService().listFiles( + uri: _currentPath, + pageSize: 50, + ).timeout(timeout); + + final List filesData = response['files'] as List? ?? []; + final pagination = response['pagination'] as Map? ?? {}; + AppLogger.d("获取files列表: $filesData"); + setState(() { + _files = filesData + .map((f) => FileModel.fromJson(f as Map)) + .toList(); + _hasMore = pagination['next_token'] != null; + _contextHint = response['context_hint'] as String?; + }); + } on TimeoutException { + setState(() { + _errorMessage = '加载超时,请检查网络后重试'; + _hasMore = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _hasMore = false; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + /// 进入文件夹 + Future enterFolder(String path) async { + _currentPath = path; + _selectedFiles.clear(); + _highlightPath = null; + _highlightTimer?.cancel(); + ThumbnailService.instance.clearAll(); + await loadFiles(); + } + + /// 返回上级 + Future goBack() async { + if (_currentPath == '/' || _currentPath.isEmpty) return; + + final parts = _currentPath.split('/'); + if (parts.length > 1) { + parts.removeLast(); + _currentPath = parts.join('/'); + } else { + _currentPath = '/'; + } + _selectedFiles.clear(); + _highlightPath = null; + _highlightTimer?.cancel(); + ThumbnailService.instance.clearAll(); + notifyListeners(); + await loadFiles(); + } + + /// 选择/取消选择文件 + void toggleSelection(String path) { + if (_selectedFiles.contains(path)) { + _selectedFiles.remove(path); + } else { + _selectedFiles.add(path); + } + notifyListeners(); + } + + /// 选择所有 + void selectAll() { + _selectedFiles = _files.map((f) => f.path).toList(); + notifyListeners(); + } + + /// 清除选择 + void clearSelection() { + _selectedFiles.clear(); + notifyListeners(); + } + + /// 切换视图类型 + void setViewType(FileViewType type) { + _viewType = type; + notifyListeners(); + } + + /// 设置错误信息 + void setErrorMessage(String? message) { + _errorMessage = message; + notifyListeners(); + } + + /// 设置状态 + void setState(VoidCallback fn) { + fn(); + notifyListeners(); + } + + /// 删除选中的文件 + Future deleteSelectedFiles() async { + if (_selectedFiles.isEmpty) return null; + + try { + AppLogger.d("删除文件: ${_selectedFiles.join(', ')}"); + await FileService().deleteFiles(uris: _selectedFiles); + + setState(() { + _files.removeWhere((file) => _selectedFiles.contains(file.path)); + }); + + clearSelection(); + return null; + } catch (e) { + final error = e.toString(); + setErrorMessage(error); + return error; + } + } + + /// 创建文件夹 + Future createFolder(String name) async { + try { + String uri; + if (_currentPath == '/' || _currentPath.isEmpty) { + uri = '/$name'; + } else { + uri = '$_currentPath/$name'; + } + + final response = await FileService().createFile( + uri: uri, + type: 'folder', + errOnConflict: true, + ); + + final newFolder = FileModel.fromJson(response); + + setState(() { + _files.insert(0, newFolder); + }); + + return null; + } catch (e) { + final error = e.toString(); + setErrorMessage(error); + return error; + } + } + + /// 删除单个文件(增量移除) + Future deleteFile(String path) async { + try { + await FileService().deleteFiles(uris: [path]); + setState(() { + _files.removeWhere((file) => file.path == path); + _selectedFiles.remove(path); + }); + return null; + } catch (e) { + final error = e.toString(); + setErrorMessage(error); + return error; + } + } + + /// 移动文件(增量更新) + Future moveFiles(List uris, String destination, {bool copy = false}) async { + try { + await FileService().moveFiles(uris: uris, dst: destination); + clearSelection(); + + if (!copy) { + // 移动:文件离开当前目录,直接从列表移除 + setState(() { + _files.removeWhere((file) => uris.contains(file.path)); + }); + } else { + // 复制:仅当目标是当前目录时需要刷新 + final normalizedDst = FileUtils.toCloudreveUri(destination); + final normalizedCur = FileUtils.toCloudreveUri(_currentPath); + if (normalizedDst == normalizedCur) { + await loadFiles(); + } + } + return null; + } catch (e) { + final error = e.toString(); + setErrorMessage(error); + return error; + } + } + + /// 重命名文件(原地更新,不刷新列表) + Future renameFile(String path, String newName) async { + try { + final response = await FileService().renameFile(uri: path, newName: newName); + if (response.isEmpty) { + await loadFiles(); + return null; + } + final updatedFile = FileModel.fromJson(response); + final index = _files.indexWhere((f) => f.path == path); + if (index != -1) { + setState(() { + _files[index] = updatedFile; + }); + } + return null; + } catch (e) { + final error = e.toString(); + setErrorMessage(error); + return error; + } + } + + /// 通过 URI 获取文件信息并添加到列表(用于上传完成后) + Future addFileByUri(String fileUri) async { + try { + final response = await FileService().getFileInfo(uri: fileUri); + final newFile = FileModel.fromJson(response); + final exists = _files.any((f) => f.id == newFile.id); + if (!exists) { + setState(() { + _files.insert(0, newFile); + }); + } + } catch (e) { + AppLogger.d('获取上传文件信息失败: $e'); + } + } + + /// 高亮指定文件路径(3 秒后自动清除) + void setHighlightPath(String? path) { + _highlightTimer?.cancel(); + _highlightPath = path; + notifyListeners(); + if (path != null) { + _highlightTimer = Timer(const Duration(seconds: 3), () { + _highlightPath = null; + notifyListeners(); + }); + } + } + + /// 导航到指定文件夹并高亮目标文件 + Future navigateAndHighlight(String folderPath, String filePath) async { + _currentPath = folderPath; + _selectedFiles.clear(); + _highlightPath = null; + _highlightTimer?.cancel(); + await loadFiles(); + setHighlightPath(filePath); + } + + /// 清空文件列表 + void clearFiles() { + setState(() { + _files = []; + _selectedFiles = []; + _currentPath = '/'; + _errorMessage = null; + }); + } + + /// 智能刷新 - 只更新差异部分 + Future refreshFiles({Duration timeout = const Duration(seconds: 5)}) async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await FileService().listFiles( + uri: _currentPath, + pageSize: 50, + ).timeout(timeout); + + final List filesData = response['files'] as List? ?? []; + final newFiles = filesData + .map((f) => FileModel.fromJson(f as Map)) + .toList(); + + final currentMap = {}; + for (final file in _files) { + currentMap[file.path] = file; + } + + final newMap = {}; + for (final file in newFiles) { + newMap[file.path] = file; + } + + int added = 0; + int removed = 0; + int updated = 0; + + final updatedFiles = []; + + for (final file in newFiles) { + final existingFile = currentMap[file.path]; + if (existingFile != null) { + if (existingFile.updatedAt != file.updatedAt || + existingFile.size != file.size) { + updatedFiles.add(file); + updated++; + } else { + updatedFiles.add(existingFile); + } + } else { + updatedFiles.add(file); + added++; + } + } + + for (final file in _files) { + if (!newMap.containsKey(file.path)) { + removed++; + } + } + + setState(() { + _files = updatedFiles; + _hasMore = response['pagination']?['next_token'] != null; + _contextHint = response['context_hint'] as String?; + }); + + return RefreshResult(added: added, removed: removed, updated: updated); + } on TimeoutException { + setState(() { + _errorMessage = '加载超时,请检查网络后重试'; + }); + return const RefreshResult(added: 0, removed: 0, updated: 0); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + }); + return const RefreshResult(added: 0, removed: 0, updated: 0); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _highlightTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/presentation/providers/navigation_provider.dart b/lib/presentation/providers/navigation_provider.dart new file mode 100644 index 0000000..983ebe5 --- /dev/null +++ b/lib/presentation/providers/navigation_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; + +class NavigationProvider extends ChangeNotifier { + int _currentIndex = 0; // 默认概览页 + + int get currentIndex => _currentIndex; + + void setIndex(int index) { + if (_currentIndex != index) { + _currentIndex = index; + notifyListeners(); + } + } +} diff --git a/lib/presentation/providers/theme_provider.dart b/lib/presentation/providers/theme_provider.dart new file mode 100644 index 0000000..bb88c77 --- /dev/null +++ b/lib/presentation/providers/theme_provider.dart @@ -0,0 +1,249 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../services/storage_service.dart'; + +/// 主题模式 +enum AppThemeMode { + light, + dark, + system, +} + +/// 主题Provider - 管理主题模式和主题色 +class ThemeProvider extends ChangeNotifier { + AppThemeMode _themeMode = AppThemeMode.system; + Color _seedColor = const Color(0xFF3B82F6); + + static const Color lightScaffoldBg = Color(0xFFF8FAFC); + static const Color darkScaffoldBg = Color(0xFF0F172A); + + AppThemeMode get themeMode => _themeMode; + Color get seedColor => _seedColor; + bool get isDark => _themeMode == AppThemeMode.dark; + + /// 初始化 + Future init() async { + await Future.wait([ + loadThemeMode(), + loadSeedColor(), + ]); + } + + /// 加载主题模式 + Future loadThemeMode() async { + final savedMode = await StorageService.instance.themeMode; + if (savedMode != null) { + switch (savedMode) { + case 'light': + _themeMode = AppThemeMode.light; + case 'dark': + _themeMode = AppThemeMode.dark; + default: + _themeMode = AppThemeMode.system; + } + } + notifyListeners(); + } + + /// 加载主题色 + Future loadSeedColor() async { + final saved = await StorageService.instance.getString('theme_seed_color'); + if (saved != null && saved.isNotEmpty) { + final color = _colorFromHex(saved); + if (color != null) { + _seedColor = color; + notifyListeners(); + } + } + } + + /// 设置主题模式 + Future setThemeMode(AppThemeMode mode) async { + _themeMode = mode; + notifyListeners(); + + String modeString; + switch (mode) { + case AppThemeMode.light: + modeString = 'light'; + case AppThemeMode.dark: + modeString = 'dark'; + case AppThemeMode.system: + modeString = 'system'; + } + await StorageService.instance.setThemeMode(modeString); + } + + /// 设置主题色 + Future setSeedColor(Color color) async { + _seedColor = color; + notifyListeners(); + await StorageService.instance.setString('theme_seed_color', _colorToHex(color)); + } + + /// 切换主题 + Future toggleTheme() async { + final newMode = isDark ? AppThemeMode.light : AppThemeMode.dark; + await setThemeMode(newMode); + } + + /// 构建亮色主题 + ThemeData buildLightTheme() { + return _buildTheme(Brightness.light); + } + + /// 构建暗色主题 + ThemeData buildDarkTheme() { + return _buildTheme(Brightness.dark); + } + + ThemeData _buildTheme(Brightness brightness) { + final isLight = brightness == Brightness.light; + final colorScheme = ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: brightness, + ); + + final bodyColor = isLight ? Colors.black87 : Colors.white; + final displayColor = isLight ? Colors.black87 : Colors.white; + + final baseTextTheme = ThemeData(brightness: brightness).textTheme; + var textTheme = baseTextTheme.apply( + bodyColor: bodyColor, + displayColor: displayColor, + fontFamily: _getPlatformFont(), + ); + + if (_getPlatformFont() == 'NotoSansSC') { + textTheme = textTheme.copyWith( + bodyLarge: textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + bodyMedium: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + bodySmall: textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + titleLarge: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + titleMedium: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + titleSmall: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), + labelLarge: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), + ); + } + + return ThemeData( + textTheme: textTheme, + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: isLight ? lightScaffoldBg : darkScaffoldBg, + appBarTheme: AppBarTheme( + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: isLight + ? lightScaffoldBg.withValues(alpha: 0.85) + : darkScaffoldBg.withValues(alpha: 0.85), + surfaceTintColor: Colors.transparent, + foregroundColor: isLight ? Colors.black87 : Colors.white, + ), + cardTheme: CardThemeData( + elevation: 0, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isLight + ? Colors.black.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.08), + ), + ), + color: isLight ? Colors.white : const Color(0xFF1E293B), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + bottomSheetTheme: const BottomSheetThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + ), + navigationBarTheme: NavigationBarThemeData( + elevation: 0, + backgroundColor: isLight + ? lightScaffoldBg.withValues(alpha: 0.9) + : darkScaffoldBg.withValues(alpha: 0.9), + indicatorColor: colorScheme.primary.withValues(alpha: 0.12), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ), + navigationRailTheme: NavigationRailThemeData( + elevation: 0, + backgroundColor: isLight + ? lightScaffoldBg + : darkScaffoldBg, + indicatorColor: colorScheme.primary.withValues(alpha: 0.12), + ), + ); + } + + /// Color → hex string (不含alpha) + static String _colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}'; + } + + /// hex string → Color + static Color? _colorFromHex(String hex) { + final clean = hex.replaceFirst('#', ''); + if (clean.length == 6) { + return Color(int.parse('FF$clean', radix: 16)); + } + if (clean.length == 8) { + return Color(int.parse(clean, radix: 16)); + } + return null; + } + + String? _getPlatformFont() { + if (Platform.isWindows || Platform.isLinux) return 'NotoSansSC'; + if (Platform.isMacOS) return 'PingFang SC'; + return null; + } +} diff --git a/lib/presentation/providers/upload_manager_provider.dart b/lib/presentation/providers/upload_manager_provider.dart new file mode 100644 index 0000000..5307376 --- /dev/null +++ b/lib/presentation/providers/upload_manager_provider.dart @@ -0,0 +1,103 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../data/models/upload_task_model.dart'; +import '../../services/upload_service.dart'; + +/// 上传管理Provider +class UploadManagerProvider extends ChangeNotifier { + final UploadService _uploadService = UploadService.instance; + bool _isInitialized = false; + bool _shouldShowDialog = false; + + bool get showUploadDialog => _shouldShowDialog && _uploadService.allTasks.isNotEmpty; + List get allTasks => _uploadService.allTasks; + List get activeTasks => _uploadService.activeTasks; + + /// 初始化上传管理器 + Future initialize() async { + if (_isInitialized) return; + await _uploadService.initialize(); + _uploadService.addListener(_onServiceChanged); + _isInitialized = true; + } + + void _onServiceChanged() { + notifyListeners(); + } + + /// 标记应该显示对话框 + void markShouldShowDialog() { + _shouldShowDialog = true; + notifyListeners(); + } + + /// 隐藏对话框 + void hideDialog() { + _shouldShowDialog = false; + notifyListeners(); + } + + /// 开始上传 + Future startUpload( + List files, + String targetPath, + ) async { + for (final file in files) { + // 构建目标路径 URI + String uri; + if (targetPath.startsWith('cloudreve://my')) { + uri = targetPath; + } else { + // 移除前导斜杠避免重复 + String pathPart = targetPath; + if (pathPart.startsWith('/')) { + pathPart = pathPart.substring(1); + } + uri = pathPart.isEmpty ? 'cloudreve://my' : 'cloudreve://my/$pathPart'; + } + + final task = UploadTaskModel( + id: DateTime.now().millisecondsSinceEpoch.toString() + file.path, + file: file, + fileName: file.uri.pathSegments.last, + fileSize: await file.length(), + targetPath: uri, + ); + _uploadService.addTask(task); + + // 开始上传任务 + _uploadService.startUpload(task); + } + } + + /// 取消上传 + void cancelUpload(String taskId) { + _uploadService.cancelUpload(taskId); + } + + /// 重试上传 + void retryUpload(String taskId) { + _uploadService.retryUpload(taskId); + } + + /// 删除任务 + void removeTask(String taskId) { + _uploadService.removeTask(taskId); + } + + /// 清除所有已完成的任务 + void clearCompletedTasks() { + _uploadService.clearCompletedTasks(); + } + + /// 清除失败的任务 + void clearFailedTasks() { + _uploadService.clearFailedTasks(); + } + + @override + void dispose() { + _uploadService.removeListener(_onServiceChanged); + super.dispose(); + } +} diff --git a/lib/presentation/providers/user_setting_provider.dart b/lib/presentation/providers/user_setting_provider.dart new file mode 100644 index 0000000..2279ae6 --- /dev/null +++ b/lib/presentation/providers/user_setting_provider.dart @@ -0,0 +1,251 @@ +import 'package:flutter/foundation.dart'; +import '../../data/models/user_setting_model.dart'; +import '../../services/user_setting_service.dart'; +import '../../core/utils/app_logger.dart'; + +/// 用户设置状态 +enum UserSettingState { idle, loading, error } + +/// 用户设置 Provider +class UserSettingProvider extends ChangeNotifier { + UserSettingState _state = UserSettingState.idle; + UserSettingModel? _settings; + UserCapacityModel? _capacity; + String? _errorMessage; + + UserSettingState get state => _state; + UserSettingModel? get settings => _settings; + UserCapacityModel? get capacity => _capacity; + String? get errorMessage => _errorMessage; + bool get isLoading => _state == UserSettingState.loading; + + final UserSettingService _service = UserSettingService.instance; + + /// 加载用户设置 + Future loadSettings() async { + try { + _setState(UserSettingState.loading); + _settings = await _service.getUserSetting(); + _setState(UserSettingState.idle); + } catch (e) { + AppLogger.d('加载用户设置失败: $e'); + _errorMessage = e.toString(); + _setState(UserSettingState.error); + } + } + + /// 加载存储用量 + Future loadCapacity() async { + try { + _capacity = await _service.getUserCapacity(); + notifyListeners(); + } catch (e) { + AppLogger.d('加载存储用量失败: $e'); + } + } + + /// 同时加载设置和容量 + Future loadAll() async { + await Future.wait([ + loadSettings(), + loadCapacity(), + ]); + } + + /// 修改昵称 + Future updateNick(String nick) async { + try { + await _service.updateNick(nick); + // 成功后刷新设置 + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('修改昵称失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 修改主题色 + Future updatePreferredTheme(String themeColor) async { + try { + await _service.updatePreferredTheme(themeColor); + if (_settings != null) { + _settings = _settings!.copyWith(); // 本地无需维护此字段 + } + return true; + } catch (e) { + AppLogger.d('修改主题色失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 修改语言 + Future updateLanguage(String language) async { + try { + await _service.updateLanguage(language); + return true; + } catch (e) { + AppLogger.d('修改语言失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 修改密码 + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + try { + await _service.changePassword( + currentPassword: currentPassword, + newPassword: newPassword, + ); + return true; + } catch (e) { + AppLogger.d('修改密码失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 启用2FA + Future prepare2FA() async { + try { + return await _service.prepare2FA(); + } catch (e) { + AppLogger.d('准备启用2FA失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return null; + } + } + + Future enable2FA(String code) async { + try { + await _service.enable2FA(code); + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('启用2FA失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 禁用2FA + Future disable2FA(String code) async { + try { + await _service.disable2FA(code); + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('禁用2FA失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 更新版本保留设置 + Future updateVersionRetention({ + bool? enabled, + List? ext, + int? max, + }) async { + try { + await _service.updateUserSetting( + versionRetentionEnabled: enabled, + versionRetentionExt: ext, + versionRetentionMax: max, + ); + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('更新版本保留设置失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 更新视图同步 + Future updateViewSync(bool disabled) async { + try { + await _service.updateUserSetting(disableViewSync: disabled); + if (_settings != null) { + _settings = _settings!.copyWith(disableViewSync: disabled); + } + notifyListeners(); + return true; + } catch (e) { + AppLogger.d('更新视图同步设置失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 更新分享链接可见性 + Future updateShareLinksInProfile(String value) async { + try { + await _service.updateUserSetting(shareLinksInProfile: value); + if (_settings != null) { + _settings = _settings!.copyWith(shareLinksInProfile: value); + } + notifyListeners(); + return true; + } catch (e) { + AppLogger.d('更新分享链接可见性失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 撤销OAuth授权 + Future revokeOAuthGrant(String appId) async { + try { + await _service.revokeOAuthGrant(appId); + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('撤销OAuth授权失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 解绑OIDC提供商 + Future unlinkOpenId(int provider) async { + try { + await _service.unlinkOpenId(provider); + await loadSettings(); + return true; + } catch (e) { + AppLogger.d('解绑OIDC失败: $e'); + _errorMessage = e.toString(); + notifyListeners(); + return false; + } + } + + /// 清除错误 + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setState(UserSettingState state) { + _state = state; + notifyListeners(); + } +} diff --git a/lib/presentation/widgets/code_wrapper.dart b/lib/presentation/widgets/code_wrapper.dart new file mode 100644 index 0000000..7df0dff --- /dev/null +++ b/lib/presentation/widgets/code_wrapper.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CodeWrapperWidget extends StatefulWidget { + final Widget child; + final String text; + final String language; + + const CodeWrapperWidget(this.child, this.text, this.language, {super.key}); + + @override + State createState() => _PreWrapperState(); +} + +class _PreWrapperState extends State { + late Widget _switchWidget; + bool hasCopied = false; + + @override + void initState() { + super.initState(); + _switchWidget = Icon(Icons.copy_rounded, key: UniqueKey()); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Stack( + children: [ + widget.child, + Align( + alignment: Alignment.topRight, + child: Container( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.language.isNotEmpty) + SelectionContainer.disabled( + child: Container( + margin: EdgeInsets.only(right: 2), + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + width: 0.5, + color: isDark ? Colors.white : Colors.black)), + child: Text(widget.language), + )), + InkWell( + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: _switchWidget, + ), + onTap: () async { + if (hasCopied) return; + await Clipboard.setData(ClipboardData(text: widget.text)); + _switchWidget = Icon(Icons.check, key: UniqueKey()); + refresh(); + Future.delayed(Duration(seconds: 2), () { + hasCopied = false; + _switchWidget = + Icon(Icons.copy_rounded, key: UniqueKey()); + refresh(); + }); + }, + ), + ], + ), + ), + ) + ], + ); + } + + void refresh() { + if (mounted) setState(() {}); + } +} \ No newline at end of file diff --git a/lib/presentation/widgets/desktop_constrained.dart b/lib/presentation/widgets/desktop_constrained.dart new file mode 100644 index 0000000..fcbaa39 --- /dev/null +++ b/lib/presentation/widgets/desktop_constrained.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// 桌面端内容宽度约束 +/// 屏幕宽度 >= 1000px 时,将子组件内容限制在 maxContentWidth 内居中显示 +class DesktopConstrained extends StatelessWidget { + final Widget child; + final double maxContentWidth; + + const DesktopConstrained({ + super.key, + required this.child, + this.maxContentWidth = 800, + }); + + static const double desktopBreakpoint = 1000; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth >= desktopBreakpoint) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxContentWidth), + child: child, + ), + ); + } + return child; + } +} diff --git a/lib/presentation/widgets/desktop_title_bar.dart b/lib/presentation/widgets/desktop_title_bar.dart new file mode 100644 index 0000000..6b2ff34 --- /dev/null +++ b/lib/presentation/widgets/desktop_title_bar.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import '../../config/app_config.dart'; + +class DesktopTitleBar extends StatelessWidget implements PreferredSizeWidget { + const DesktopTitleBar({super.key}); + + @override + Widget build(BuildContext context) { + return WindowCaption( + brightness: Theme.of(context).brightness, + backgroundColor: Colors.transparent, // 透明背景,露出下面的组件颜色 + title: Row( + children: [ + // 可以在这里放一个小 Logo + Image.asset( + 'assets/icons/tray_icon.png', + width: 20, + height: 20, + ), + const SizedBox(width: 10), + const Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'NotoSansSC', + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(32); // Windows 标准标题栏高度 +} diff --git a/lib/presentation/widgets/download_progress_dialog.dart b/lib/presentation/widgets/download_progress_dialog.dart new file mode 100644 index 0000000..f905e4e --- /dev/null +++ b/lib/presentation/widgets/download_progress_dialog.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../data/models/download_task_model.dart'; +import '../providers/download_manager_provider.dart'; +import 'download_progress_item.dart'; + +/// 下载进度对话框 +class DownloadProgressDialog extends StatelessWidget { + const DownloadProgressDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, downloadManager, child) { + final tasks = downloadManager.tasks; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 300, + // 当没有任务时,最小高度约为2个任务的大小(约160px) + minHeight: tasks.isEmpty ? 160 : 0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 标题栏 + _buildHeader(context), + + // 任务列表 + Flexible( + child: tasks.isEmpty + ? _buildEmptyState(context) + : _buildTaskList(context, downloadManager, tasks), + ), + + // 底部操作栏 + _buildFooter(context, tasks), + ], + ), + ), + ); + }, + ); + } + + Widget _buildEmptyState(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_done, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + '暂无下载任务', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildTaskList( + BuildContext context, + DownloadManagerProvider downloadManager, + List tasks, + ) { + return ListView.separated( + shrinkWrap: true, + itemCount: tasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadProgressItem( + key: ValueKey(task.id), + task: task, + onPause: () => downloadManager.pauseDownload(task.id), + onResume: () => downloadManager.resumeDownload(task.id), + onCancel: () => downloadManager.cancelDownload(task.id), + onDelete: () async { + await downloadManager.deleteDownloadTask(task.id); + }, + onRetry: () => downloadManager.retryDownload(task.id), + ); + }, + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + const Icon(Icons.download, color: Colors.blue), + const SizedBox(width: 12), + const Text( + '下载任务', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Consumer( + builder: (context, downloadManager, child) { + final downloadingCount = downloadManager.downloadingCount; + if (downloadingCount > 0) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '下载中: $downloadingCount', + style: const TextStyle( + fontSize: 12, + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + tooltip: '关闭', + ), + ], + ), + ); + } + + Widget _buildFooter(BuildContext context, List tasks) { + final hasCompleted = tasks.any((t) => t.status == DownloadStatus.completed); + final hasFailed = tasks.any((t) => t.status == DownloadStatus.failed); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + if (hasCompleted) + Consumer( + builder: (context, downloadManager, child) { + return TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('清除已完成'), + onPressed: () async { + await downloadManager.clearCompletedTasks(); + }, + ); + }, + ), + if (hasFailed) + Consumer( + builder: (context, downloadManager, child) { + return TextButton.icon( + icon: const Icon(Icons.clear_all, size: 18), + label: const Text('清除失败'), + onPressed: () { + downloadManager.clearFailedTasks(); + }, + ); + }, + ), + const Spacer(), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('关闭'), + ), + ], + ), + ); + } +} + +/// 显示下载对话框 +void showDownloadDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const DownloadProgressDialog(), + barrierDismissible: false, + ); +} diff --git a/lib/presentation/widgets/download_progress_item.dart b/lib/presentation/widgets/download_progress_item.dart new file mode 100644 index 0000000..0cffad5 --- /dev/null +++ b/lib/presentation/widgets/download_progress_item.dart @@ -0,0 +1,418 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:open_file/open_file.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import '../../data/models/download_task_model.dart'; +import '../../services/download_service.dart'; +import '../providers/download_manager_provider.dart'; +import 'toast_helper.dart'; +import '../../core/utils/app_logger.dart'; + +/// 下载任务列表项 +class DownloadProgressItem extends StatelessWidget { + final DownloadTaskModel task; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onCancel; + final VoidCallback? onDelete; + final VoidCallback? onRetry; + + const DownloadProgressItem({ + super.key, + required this.task, + this.onPause, + this.onResume, + this.onCancel, + this.onDelete, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final downloadManager = Provider.of( + context, + listen: true, + ); + final latestTask = downloadManager.getTask(task.id) ?? task; + + final isDownloading = latestTask.status == DownloadStatus.downloading; + final isPaused = latestTask.status == DownloadStatus.paused; + final isFailed = latestTask.status == DownloadStatus.failed; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: _getCardColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getBorderColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi), + ), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildStatusIcon(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.fileName, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + _buildStatusRow(context, latestTask), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: _buildActionButtons(context, latestTask), + ), + ], + ), + if (isDownloading || isPaused) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: isPaused ? null : latestTask.progress, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Text( + isPaused ? '已暂停' : latestTask.progressText, + style: const TextStyle(fontSize: 12), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '${DownloadService.getReadableFileSize(latestTask.downloadedBytes)} / ' + '${DownloadService.getReadableFileSize(latestTask.fileSize)}', + style: const TextStyle(fontSize: 12), + ), + if (latestTask.speedText.isNotEmpty) ...[ + const SizedBox(width: 12), + Text( + latestTask.speedText, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ], + ), + ] else if (isFailed && latestTask.errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + latestTask.errorMessage!, + style: TextStyle(fontSize: 12, color: Colors.red.shade700), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatusIcon(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) { + final color = _getStatusColor(status, waitingForWifi: waitingForWifi); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _getStatusIcon(status, waitingForWifi: waitingForWifi), + size: 18, + color: color, + ), + ); + } + + Widget _buildStatusRow(BuildContext context, DownloadTaskModel task) { + final color = _getStatusColor(task.status, waitingForWifi: task.waitingForWifi); + final isCompleted = task.status == DownloadStatus.completed; + + return Row( + children: [ + Text( + task.statusText, + style: TextStyle(fontSize: 12, color: color), + ), + if (isCompleted) ...[ + Text( + ' · ', + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + DownloadService.getReadableFileSize(task.fileSize), + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + ' · ', + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + _formatDateTime(task.completedAt!), + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + ], + ], + ); + } + + List _buildActionButtons( + BuildContext context, + DownloadTaskModel task, + ) { + final errorColor = Theme.of(context).colorScheme.error; + + switch (task.status) { + case DownloadStatus.waiting: + if (task.waitingForWifi) { + return [ + IconButton( + icon: Icon(Icons.cancel, size: 20, color: errorColor), + onPressed: onCancel, + tooltip: '取消', + ), + ]; + } + return []; + case DownloadStatus.downloading: + return [ + IconButton( + icon: const Icon(Icons.pause, size: 20), + onPressed: onPause, + tooltip: '暂停', + ), + ]; + case DownloadStatus.paused: + return [ + IconButton( + icon: const Icon(Icons.play_arrow, size: 20), + onPressed: onResume, + tooltip: '继续', + ), + IconButton( + icon: Icon(Icons.cancel, size: 20, color: errorColor), + onPressed: onCancel, + tooltip: '取消', + ), + ]; + case DownloadStatus.failed: + return [ + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: onRetry, + tooltip: '重试', + ), + IconButton( + icon: Icon(Icons.delete, size: 20, color: errorColor), + onPressed: onDelete, + tooltip: '删除', + ), + ]; + case DownloadStatus.completed: + final showOpenFolder = !Platform.isAndroid; + return [ + IconButton( + icon: const Icon(Icons.open_in_new, size: 20), + onPressed: () => _openDownloadedFile(context, task), + tooltip: '打开', + ), + if (showOpenFolder) + IconButton( + icon: const Icon(Icons.folder_open, size: 20), + onPressed: () => _openFileFolder(context, task), + tooltip: '打开文件夹', + ), + IconButton( + icon: Icon(Icons.delete_outline, size: 20, color: errorColor), + onPressed: onDelete, + tooltip: '删除', + ), + ]; + case DownloadStatus.cancelled: + return []; + } + } + + Future checkInstallPermission() async { + if (await Permission.requestInstallPackages.isDenied) { + await Permission.requestInstallPackages.request(); + } + } + + Future _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 _openFileFolder( + BuildContext context, + DownloadTaskModel task, + ) async { + try { + final dir = File(task.savePath).parent.path; + final result = await OpenFile.open(dir); + if (result.type != ResultType.done && context.mounted) { + ToastHelper.error('无法打开文件夹:${result.message}'); + } + } catch (e) { + if (context.mounted) { + ToastHelper.error('打开文件夹失败:$e'); + } + } + } + + IconData _getStatusIcon(DownloadStatus status, {bool waitingForWifi = false}) { + switch (status) { + case DownloadStatus.waiting: + return waitingForWifi ? LucideIcons.wifi : LucideIcons.clock; + case DownloadStatus.downloading: + return LucideIcons.download; + case DownloadStatus.completed: + return LucideIcons.checkCircle2; + case DownloadStatus.paused: + return LucideIcons.pause; + case DownloadStatus.failed: + case DownloadStatus.cancelled: + return LucideIcons.xCircle; + } + } + + Color _getStatusColor(DownloadStatus status, {bool waitingForWifi = false}) { + switch (status) { + case DownloadStatus.waiting: + return waitingForWifi ? Colors.blue : Colors.grey; + case DownloadStatus.downloading: + return Colors.blue; + case DownloadStatus.completed: + return Colors.green; + case DownloadStatus.paused: + return Colors.orange; + case DownloadStatus.failed: + case DownloadStatus.cancelled: + return Colors.red; + } + } + + Color _getCardColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (status) { + case DownloadStatus.waiting: + if (waitingForWifi) { + return isDark ? Colors.blue.withValues(alpha: 0.08) : Colors.blue.withValues(alpha: 0.05); + } + return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6); + case DownloadStatus.completed: + return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05); + case DownloadStatus.failed: + case DownloadStatus.cancelled: + return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05); + default: + return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6); + } + } + + Color _getBorderColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (status) { + case DownloadStatus.waiting: + if (waitingForWifi) { + return Colors.blue.withValues(alpha: isDark ? 0.2 : 0.15); + } + return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3); + case DownloadStatus.completed: + return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15); + case DownloadStatus.failed: + case DownloadStatus.cancelled: + return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15); + default: + return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3); + } + } + + String _formatDateTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return '刚刚'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}分钟前'; + } else if (difference.inHours < 24) { + return '${difference.inHours}小时前'; + } else { + return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/presentation/widgets/empty_folder_view.dart b/lib/presentation/widgets/empty_folder_view.dart new file mode 100644 index 0000000..df6f8cf --- /dev/null +++ b/lib/presentation/widgets/empty_folder_view.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// 空文件夹状态组件 +class EmptyFolderView extends StatelessWidget { + final String currentPath; + + const EmptyFolderView({ + super.key, + required this.currentPath, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.folder, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + currentPath == '/' ? '文件夹为空' : '暂无文件', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + if (currentPath == '/') const SizedBox(height: 8), + if (currentPath == '/') + Text( + '点击 + 按钮创建新文件夹', + style: TextStyle(fontSize: 14, color: Colors.grey.shade500), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/exit_hint_overlay.dart b/lib/presentation/widgets/exit_hint_overlay.dart new file mode 100644 index 0000000..3ada82d --- /dev/null +++ b/lib/presentation/widgets/exit_hint_overlay.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; + +/// 退出提示覆盖层 +class ExitHintOverlay { + OverlayEntry? _overlay; + AnimationController? _animationController; + + /// 显示退出提示 + void show(BuildContext context) { + remove(); + + _overlay = OverlayEntry( + builder: (overlayContext) => Positioned.fill( + child: Center( + child: Material( + color: Colors.transparent, + child: _ExitHintWidget( + onDismiss: remove, + onAnimationReady: (controller, fadeAnimation) { + _animationController = controller; + controller.forward(); + }, + ), + ), + ), + ), + ); + Overlay.of(context).insert(_overlay!); + } + + /// 移除退出提示 + void remove() { + // 立即移除 overlay + _overlay?.remove(); + _overlay = null; + + // 停止动画 + _animationController?.stop(); + + // 清理控制器引用 + _animationController = null; + } +} + +class _ExitHintWidget extends StatefulWidget { + final VoidCallback onDismiss; + final Function(AnimationController controller, Animation fadeAnimation) onAnimationReady; + + const _ExitHintWidget({ + required this.onDismiss, + required this.onAnimationReady, + }); + + @override + State<_ExitHintWidget> createState() => _ExitHintWidgetState(); +} + +class _ExitHintWidgetState extends State<_ExitHintWidget> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + + widget.onAnimationReady(_controller, _fadeAnimation); + } + + @override + void dispose() { + _controller.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return FadeTransition( + opacity: _fadeAnimation, + child: child, + ); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth * 0.65; + + return GestureDetector( + onTap: widget.onDismiss, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + constraints: BoxConstraints(maxWidth: cardWidth), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.touch_app_rounded, + color: theme.colorScheme.error, + size: 24, + ), + ), + const SizedBox(width: 14), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '再次左滑退出应用', + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + Text( + '点击屏幕任意位置取消', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/widgets/file_breadcrumb.dart b/lib/presentation/widgets/file_breadcrumb.dart new file mode 100644 index 0000000..f6e4f9c --- /dev/null +++ b/lib/presentation/widgets/file_breadcrumb.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +/// 面包屑导航组件 +class FileBreadcrumb extends StatelessWidget { + final String currentPath; + final void Function(String path) onPathTap; + + const FileBreadcrumb({ + super.key, + required this.currentPath, + required this.onPathTap, + }); + + @override + Widget build(BuildContext context) { + final pathParts = currentPath.split('/'); + pathParts.removeWhere((part) => part.isEmpty); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border( + top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5), width: 1), + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildBreadcrumbItem( + context, + name: '首页', + path: '/', + icon: LucideIcons.home, + primaryColor: colorScheme.primary, + onTap: () => onPathTap('/'), + ), + for (int i = 0; i < pathParts.length; i++) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(LucideIcons.chevronRight, size: 16, color: theme.hintColor.withValues(alpha: 0.5)), + ), + _buildBreadcrumbItem( + context, + name: pathParts[i], + path: '/${pathParts.sublist(0, i + 1).join('/')}', + icon: null, + primaryColor: colorScheme.primary, + onTap: () => onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'), + ), + ], + ], + ), + ), + ); + } + + Widget _buildBreadcrumbItem( + BuildContext context, { + required String name, + required String path, + required IconData? icon, + required Color primaryColor, + required VoidCallback onTap, + }) { + return Container( + height: 36, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + if (icon != null) + Icon(icon, size: 16, color: primaryColor), + if (icon != null) const SizedBox(width: 5), + Text( + name, + style: TextStyle( + color: primaryColor, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/file_grid_item.dart b/lib/presentation/widgets/file_grid_item.dart new file mode 100644 index 0000000..180c30e --- /dev/null +++ b/lib/presentation/widgets/file_grid_item.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart' hide DateUtils; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../data/models/file_model.dart'; +import '../../core/utils/date_utils.dart'; +import '../../core/utils/file_icon_utils.dart'; +import '../../core/utils/file_utils.dart'; +import 'file_menu_helper.dart'; +import 'thumbnail_image.dart'; + +/// 文件网格项 +class FileGridItem extends StatelessWidget { + final FileModel file; + final bool isSelected; + final bool isHighlighted; + final bool showCheckbox; + final VoidCallback? onTap; + final VoidCallback? onSelect; + final VoidCallback? onDownload; + final VoidCallback? onOpenInBrowser; + final VoidCallback? onRename; + final VoidCallback? onMove; + final VoidCallback? onCopy; + final VoidCallback? onShare; + final VoidCallback? onDelete; + final VoidCallback? onRestore; + final VoidCallback? onInfo; + final bool tapToShowMenu; + final String? contextHint; + + const FileGridItem({ + super.key, + required this.file, + this.isSelected = false, + this.isHighlighted = false, + this.showCheckbox = false, + this.tapToShowMenu = false, + this.onTap, + this.onSelect, + this.onDownload, + this.onOpenInBrowser, + this.onRename, + this.onMove, + this.onCopy, + this.onShare, + this.onDelete, + this.onRestore, + this.onInfo, + this.contextHint, + }); + + @override + Widget build(BuildContext context) { + return Builder( + builder: (builderContext) => LayoutBuilder( + builder: (context, constraints) { + final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0); + + return _FileGridItemHover( + file: file, + isSelected: isSelected, + isHighlighted: isHighlighted, + showCheckbox: showCheckbox, + contextHint: contextHint, + fontSize: fontSize, + tapToShowMenu: tapToShowMenu, + onTap: tapToShowMenu ? null : onTap, + onLongPress: () => _showMenu(builderContext), + onSelect: onSelect, + onMore: () => _showMenu(builderContext), + ); + }, + ), + ); + } + + Future _showMenu(BuildContext context) async { + final result = await showFileMenu( + context: context, + hasSelect: onSelect != null, + hasDownload: onDownload != null, + hasOpenInBrowser: onOpenInBrowser != null, + hasRename: onRename != null, + hasMove: onMove != null, + hasCopy: onCopy != null, + hasShare: onShare != null, + hasDelete: onDelete != null, + hasRestore: onRestore != null, + hasInfo: onInfo != null, + ); + + switch (result) { + case FileMenuAction.select: + onSelect?.call(); + case FileMenuAction.download: + onDownload?.call(); + case FileMenuAction.openInBrowser: + onOpenInBrowser?.call(); + case FileMenuAction.rename: + onRename?.call(); + case FileMenuAction.move: + onMove?.call(); + case FileMenuAction.copy: + onCopy?.call(); + case FileMenuAction.share: + onShare?.call(); + case FileMenuAction.delete: + onDelete?.call(); + case FileMenuAction.restore: + onRestore?.call(); + case FileMenuAction.info: + onInfo?.call(); + case null: + break; + } + } +} + +class _FileGridItemHover extends StatefulWidget { + final FileModel file; + final bool isSelected; + final bool isHighlighted; + final bool showCheckbox; + final String? contextHint; + final double fontSize; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onSelect; + final VoidCallback? onMore; + final bool tapToShowMenu; + + const _FileGridItemHover({ + required this.file, + required this.isSelected, + required this.isHighlighted, + required this.showCheckbox, + required this.contextHint, + required this.fontSize, + this.onTap, + this.onLongPress, + this.onSelect, + this.onMore, + this.tapToShowMenu = false, + }); + + @override + State<_FileGridItemHover> createState() => _FileGridItemHoverState(); +} + +class _FileGridItemHoverState extends State<_FileGridItemHover> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + // 卡片背景 + Color cardBg; + BoxBorder? border; + List? shadows; + + if (widget.isSelected) { + cardBg = colorScheme.primary.withValues(alpha: 0.06); + border = Border.all(color: colorScheme.primary, width: 2); + } else if (widget.isHighlighted) { + cardBg = colorScheme.primary.withValues(alpha: 0.06); + border = Border.all(color: colorScheme.primary.withValues(alpha: 0.3)); + shadows = [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ]; + } else if (_isHovered) { + cardBg = isDark ? const Color(0xFF263548) : const Color(0xFFF1F5F9); + border = Border.all(color: colorScheme.primary.withValues(alpha: 0.2)); + shadows = [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ]; + } else { + cardBg = colorScheme.surfaceContainerLow; + border = Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : theme.dividerColor.withValues(alpha: 0.15), + ); + } + + // 文字颜色 + final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: GestureDetector( + onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap, + onLongPress: widget.onLongPress, + onSecondaryTap: widget.onLongPress, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(8), + border: border, + boxShadow: shadows, + ), + padding: const EdgeInsets.all(6), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 图标区 + Expanded( + child: widget.showCheckbox + ? Center( + child: Checkbox( + value: widget.isSelected, + onChanged: (_) => widget.onSelect?.call(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ) + : _buildIconArea(context), + ), + const SizedBox(height: 6), + // 文字区:左对齐 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:文件名 + Text( + _truncateFileName(widget.file.name), + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w500, + color: nameColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // 第二行:类型 | 大小 + Text( + widget.file.isFolder + ? FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: true) + : '${FileIconUtils.getFileTypeLabel(widget.file.name)} | ${DateUtils.formatFileSize(widget.file.size)}', + style: TextStyle( + fontSize: widget.fontSize * 0.85, + color: theme.hintColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + // 第三行:修改时间 + Text( + DateUtils.formatDateTime(widget.file.updatedAt), + style: TextStyle( + fontSize: widget.fontSize * 0.8, + color: theme.hintColor.withValues(alpha: 0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + // Hover 操作按钮 + if (_isHovered && !widget.showCheckbox) + Positioned( + top: 0, + right: 0, + child: Material( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: widget.onMore, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(LucideIcons.moreVertical, size: 14, color: colorScheme.primary), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + String _truncateFileName(String name) { + const maxChars = 20; + if (name.length <= maxChars) return name; + + final dotIndex = name.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < name.length - 1) { + final prefix = name.substring(0, 8); + final extension = name.substring(dotIndex); + final middleLength = maxChars - prefix.length - extension.length - 3; + if (middleLength > 0) return '$prefix...$extension'; + } + + final half = (maxChars - 3) ~/ 2; + return '${name.substring(0, half)}...${name.substring(name.length - half)}'; + } + + Widget _buildIconArea(BuildContext context) { + final file = widget.file; + final ext = FileUtils.getFileExtension(file.name); + final isThumbnailable = !file.isFolder + && FileUtils.isImageFile(file.name) + && ext != 'svg'; + + if (!isThumbnailable) { + return Center( + child: FileIconUtils.buildIconWidget( + context: context, + file: file, + size: 40, + iconSize: 22, + borderRadius: 10, + ), + ); + } + + return ThumbnailImage( + file: file, + contextHint: widget.contextHint, + borderRadius: 10, + ); + } +} diff --git a/lib/presentation/widgets/file_info_dialog.dart b/lib/presentation/widgets/file_info_dialog.dart new file mode 100644 index 0000000..18b6fea --- /dev/null +++ b/lib/presentation/widgets/file_info_dialog.dart @@ -0,0 +1,670 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../data/models/file_model.dart'; +import '../../core/utils/date_utils.dart' as date_utils; +import '../../core/utils/file_icon_utils.dart'; +import '../../core/utils/file_type_utils.dart'; +import '../../services/file_service.dart'; +import '../../router/app_router.dart'; +import 'toast_helper.dart'; + +/// 文件/文件夹详情(右侧抽屉) +class FileInfoPanel extends StatefulWidget { + final FileModel file; + const FileInfoPanel({super.key, required this.file}); + + /// 在指定 context 的 Scaffold 上打开右侧抽屉 + static void show(BuildContext context, FileModel file) { + Scaffold.of(context).openEndDrawer(); + } + + @override + State createState() => _FileInfoPanelState(); +} + +class _FileInfoPanelState extends State { + FileInfoModel? _fileInfo; + bool _isLoading = true; + bool _isCalculatingFolder = false; + bool _isVersionLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadFileInfo(); + } + + Future _loadFileInfo() async { + try { + final response = await FileService().getFileInfo( + uri: widget.file.relativePath, + extended: widget.file.isFile, + folderSummary: false, + ); + if (mounted) { + setState(() { + _fileInfo = FileInfoModel.fromJson(response); + _isLoading = false; + _isVersionLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _calculateFolderSize() async { + setState(() => _isCalculatingFolder = true); + try { + final response = await FileService().getFileInfo( + uri: widget.file.relativePath, + extended: true, + folderSummary: true, + ); + if (mounted) { + setState(() { + _fileInfo = FileInfoModel.fromJson(response); + _isCalculatingFolder = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isCalculatingFolder = false); + ToastHelper.failure('计算文件夹大小失败: $e'); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Drawer( + child: SafeArea( + right: false, + child: Column( + children: [ + Container( + padding: const EdgeInsets.only( + left: 16, + right: 8, + top: 8, + bottom: 12, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)), + ), + ), + child: Row( + children: [ + FileIconUtils.buildIconWidget( + context: context, + file: widget.file, + size: 32, + iconSize: 18, + borderRadius: 8, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.file.name, + style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _buildError(theme) + : _buildContent(theme, colorScheme), + ), + ], + ), + ), + ); + } + + Widget _buildError(ThemeData theme) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 12), + Text('加载失败', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(_error ?? '', style: TextStyle(color: theme.hintColor, fontSize: 12), textAlign: TextAlign.center), + ), + const SizedBox(height: 12), + FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')), + ], + ), + ); + } + + Widget _buildContent(ThemeData theme, ColorScheme colorScheme) { + final file = _fileInfo?.file ?? widget.file; + final typeLabel = file.isFolder + ? '文件夹' + : FileIconUtils.getFileTypeLabel(file.name); + final extendedInfo = _fileInfo?.extendedInfo; + final versionEntities = extendedInfo?.entities + ?.where((e) => e.type == 0) + .toList() ?? + []; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 类型标签 + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + typeLabel, + style: TextStyle( + fontSize: 12, + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 16), + + // 基本信息 + _buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)), + if (file.isFile) + _buildInfoRow( + LucideIcons.hardDrive, + '大小', + date_utils.DateUtils.formatFileSize(file.size), + ), + _buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(file.createdAt)), + _buildInfoRow(LucideIcons.calendar, '修改时间', date_utils.DateUtils.formatDateTime(file.updatedAt)), + if (file.owned != null) + _buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '是' : '否'), + + // 文件扩展信息 + if (file.isFile && extendedInfo != null) ...[ + const SizedBox(height: 12), + Divider(color: theme.dividerColor.withValues(alpha: 0.3)), + const SizedBox(height: 8), + Text('扩展信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + _buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id), + if (file.primaryEntity != null) + _buildInfoRow(LucideIcons.hash, '主版本', file.primaryEntity!), + if (extendedInfo.storagePolicy != null) + _buildInfoRow( + LucideIcons.server, + '存储策略', + '${extendedInfo.storagePolicy!.name} (${extendedInfo.storagePolicy!.type})', + ), + if (extendedInfo.storageUsed != null) + _buildInfoRow( + LucideIcons.database, + '总占用', + date_utils.DateUtils.formatFileSize(extendedInfo.storageUsed!), + ), + ], + + // 版本历史 + if (file.isFile && versionEntities.isNotEmpty) ...[ + const SizedBox(height: 12), + Divider(color: theme.dividerColor.withValues(alpha: 0.3)), + const SizedBox(height: 8), + _buildVersionSection(theme, colorScheme, versionEntities), + ], + + // 文件夹信息 + if (file.isFolder) ...[ + const SizedBox(height: 12), + Divider(color: theme.dividerColor.withValues(alpha: 0.3)), + const SizedBox(height: 8), + Text('文件夹信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + _buildFolderSummary(theme, colorScheme), + ], + ], + ), + ); + } + + Widget _buildVersionSection( + ThemeData theme, + ColorScheme colorScheme, + List entities, + ) { + final file = _fileInfo!.file; + final primaryEntity = file.primaryEntity; + final isPreviewable = FileTypeUtils.isPreviewable(file.name); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${entities.length}', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...entities.asMap().entries.map((entry) { + final index = entry.key; + final entity = entry.value; + final isCurrent = entity.id == primaryEntity; + final versionNumber = entities.length - index; + return _buildVersionItem( + theme, + colorScheme, + entity: entity, + versionNumber: versionNumber, + isCurrent: isCurrent, + isPreviewable: isPreviewable, + file: file, + ); + }), + ], + ); + } + + Widget _buildVersionItem( + ThemeData theme, + ColorScheme colorScheme, { + required EntityModel entity, + required int versionNumber, + required bool isCurrent, + required bool isPreviewable, + required FileModel file, + }) { + final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id; + final createdBy = entity.createdBy?.nickname ?? '未知'; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + decoration: BoxDecoration( + color: isCurrent + ? colorScheme.primaryContainer.withValues(alpha: 0.15) + : null, + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.15)), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 380; + + final actionButtons = [ + if (isPreviewable) + _buildVersionActionButton( + icon: LucideIcons.externalLink, + tooltip: '打开', + onPressed: _isVersionLoading ? null : () => _openVersion(entity), + ), + _buildVersionActionButton( + icon: LucideIcons.download, + tooltip: '下载', + onPressed: _isVersionLoading ? null : () => _downloadVersion(entity), + ), + if (!isCurrent) ...[ + _buildVersionActionButton( + icon: LucideIcons.pin, + tooltip: '设为当前版本', + onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity), + ), + _buildVersionActionButton( + icon: LucideIcons.trash2, + tooltip: '删除', + color: colorScheme.error, + onPressed: _isVersionLoading ? null : () => _deleteVersion(entity), + ), + ], + ]; + + final versionBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isCurrent + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + isCurrent ? '当前' : 'v$versionNumber', + style: TextStyle( + fontSize: 11, + color: isCurrent + ? colorScheme.onPrimaryContainer + : theme.hintColor, + fontWeight: FontWeight.w600, + ), + ), + ); + + final versionInfo = Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => _showVersionDetail(entity), + onLongPress: () => _showVersionDetail(entity), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$shortId · ${date_utils.DateUtils.formatFileSize(entity.size)}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + Text( + '${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy', + style: const TextStyle(fontSize: 11, color: null) + .copyWith(color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (isNarrow) ...[ + const SizedBox(height: 4), + Row(children: actionButtons), + ], + ], + ), + ); + + if (isNarrow) { + return Row( + children: [versionBadge, const SizedBox(width: 8), versionInfo], + ); + } + + return Row( + children: [ + versionBadge, + const SizedBox(width: 8), + versionInfo, + ...actionButtons, + ], + ); + }, + ), + ); + } + + void _showVersionDetail(EntityModel entity) { + final createdBy = entity.createdBy; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('版本详情'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(LucideIcons.hash, 'ID', entity.id), + _buildInfoRow(LucideIcons.hardDrive, '大小', date_utils.DateUtils.formatFileSize(entity.size)), + _buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(entity.createdAt)), + if (createdBy != null) ...[ + _buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname), + _buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id), + ], + if (entity.storagePolicy != null) + _buildInfoRow(LucideIcons.server, '存储策略', '${entity.storagePolicy!.name} (${entity.storagePolicy!.type})'), + if (entity.encryptedWith != null) + _buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('关闭'), + ), + ], + ), + ); + } + + Widget _buildVersionActionButton({ + required IconData icon, + required String tooltip, + Color? color, + required VoidCallback? onPressed, + }) { + return IconButton( + icon: Icon(icon, size: 16, color: color), + onPressed: onPressed, + tooltip: tooltip, + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(28, 28), + ), + ); + } + + // ─── 版本操作 ─── + + void _openVersion(EntityModel entity) { + final file = _fileInfo!.file; + if (!FileTypeUtils.isPreviewable(file.name)) { + ToastHelper.info('暂不支持预览此文件类型'); + return; + } + + final args = {'file': file, 'entityId': entity.id}; + + if (FileTypeUtils.isImage(file.name)) { + Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: args); + } else if (FileTypeUtils.isPdf(file.name)) { + Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: args); + } else if (FileTypeUtils.isVideo(file.name)) { + Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: args); + } else if (FileTypeUtils.isAudio(file.name)) { + Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args); + } else if (FileTypeUtils.isMarkdown(file.name)) { + Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: args); + } else if (FileTypeUtils.isTextCode(file.name)) { + Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args); + } + } + + Future _downloadVersion(EntityModel entity) async { + try { + final response = await FileService().getDownloadUrls( + uris: [widget.file.relativePath], + entity: entity.id, + download: true, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isNotEmpty) { + final urlData = urls[0] as Map; + final url = urlData['url'] as String; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } else { + if (mounted) ToastHelper.error('无法打开下载链接'); + } + } else { + if (mounted) ToastHelper.error('获取下载链接失败'); + } + } catch (e) { + if (mounted) ToastHelper.failure('获取下载链接失败: $e'); + } + } + + Future _setCurrentVersion(EntityModel entity) async { + setState(() => _isVersionLoading = true); + try { + await FileService().setFileVersion( + uri: widget.file.relativePath, + version: entity.id, + ); + if (mounted) { + ToastHelper.success('已设为当前版本'); + _loadFileInfo(); + } + } catch (e) { + if (mounted) { + setState(() => _isVersionLoading = false); + ToastHelper.failure('操作失败: $e'); + } + } + } + + Future _deleteVersion(EntityModel entity) async { + final colorScheme = Theme.of(context).colorScheme; + final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除版本'), + content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom(backgroundColor: colorScheme.error), + child: const Text('删除'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isVersionLoading = true); + try { + await FileService().deleteFileVersion( + uri: widget.file.relativePath, + version: entity.id, + ); + if (mounted) { + ToastHelper.success('版本已删除'); + _loadFileInfo(); + } + } catch (e) { + if (mounted) { + setState(() => _isVersionLoading = false); + ToastHelper.failure('删除失败: $e'); + } + } + } + + // ─── 文件夹摘要 ─── + + Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) { + final summary = _fileInfo?.folderSummary; + + if (summary != null) { + return Column( + children: [ + _buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'), + _buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'), + _buildInfoRow(LucideIcons.hardDrive, '总大小', date_utils.DateUtils.formatFileSize(summary.size)), + if (!summary.completed) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon(LucideIcons.alertCircle, size: 14, color: theme.colorScheme.error), + const SizedBox(width: 6), + Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)), + ], + ), + ), + const SizedBox(height: 8), + Text( + '计算于 ${date_utils.DateUtils.formatDateTime(summary.calculatedAt)}', + style: TextStyle(fontSize: 11, color: theme.hintColor), + ), + ], + ); + } + + return _isCalculatingFolder + ? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator())) + : SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _calculateFolderSize, + icon: const Icon(LucideIcons.calculator, size: 16), + label: const Text('计算文件夹大小'), + ), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: theme.hintColor), + const SizedBox(width: 8), + SizedBox( + width: 72, + child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)), + ), + Expanded( + child: SelectableText( + value, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/file_list_header.dart b/lib/presentation/widgets/file_list_header.dart new file mode 100644 index 0000000..b0e1d70 --- /dev/null +++ b/lib/presentation/widgets/file_list_header.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// 文件列表表头(桌面端) +class FileListHeader extends StatelessWidget { + final bool showCheckbox; + const FileListHeader({super.key, this.showCheckbox = false}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = TextStyle( + color: theme.hintColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)), + ), + ), + child: Row( + children: [ + if (showCheckbox) const SizedBox(width: 40), + // 图标占位 + const SizedBox(width: 36 + 16), + Expanded(flex: 5, child: Text('名称', style: style)), + Expanded(flex: 2, child: Text('修改日期', style: style)), + Expanded(flex: 1, child: Text('大小', style: style)), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/file_list_item.dart b/lib/presentation/widgets/file_list_item.dart new file mode 100644 index 0000000..514a099 --- /dev/null +++ b/lib/presentation/widgets/file_list_item.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart' hide DateUtils; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../data/models/file_model.dart'; +import '../../core/utils/date_utils.dart'; +import '../../core/utils/file_icon_utils.dart'; +import '../../services/file_service.dart'; +import 'file_menu_helper.dart'; + +/// 文件列表项 +class FileListItem extends StatelessWidget { + final FileModel file; + final bool isSelected; + final bool isHighlighted; + final bool showCheckbox; + final int index; + final bool isDesktop; + final VoidCallback? onTap; + final VoidCallback? onSelect; + final VoidCallback? onDownload; + final VoidCallback? onOpenInBrowser; + final VoidCallback? onRename; + final VoidCallback? onMove; + final VoidCallback? onCopy; + final VoidCallback? onShare; + final VoidCallback? onDelete; + final VoidCallback? onRestore; + final VoidCallback? onInfo; + final bool tapToShowMenu; + + const FileListItem({ + super.key, + required this.file, + this.isSelected = false, + this.isHighlighted = false, + this.showCheckbox = false, + this.index = 0, + this.isDesktop = true, + this.tapToShowMenu = false, + this.onTap, + this.onSelect, + this.onDownload, + this.onOpenInBrowser, + this.onRename, + this.onMove, + this.onCopy, + this.onShare, + this.onDelete, + this.onRestore, + this.onInfo, + }); + + @override + Widget build(BuildContext context) { + return _FileListItemHover( + file: file, + isSelected: isSelected, + isHighlighted: isHighlighted, + index: index, + isDesktop: isDesktop, + showCheckbox: showCheckbox, + tapToShowMenu: tapToShowMenu, + onTap: tapToShowMenu ? null : onTap, + onLongPress: () => _showMenu(context), + onSelect: onSelect, + ); + } + + Future _showMenu(BuildContext context) async { + final result = await showFileMenu( + context: context, + hasSelect: onSelect != null, + hasDownload: onDownload != null, + hasOpenInBrowser: onOpenInBrowser != null, + hasRename: onRename != null, + hasMove: onMove != null, + hasCopy: onCopy != null, + hasShare: onShare != null, + hasDelete: onDelete != null, + hasRestore: onRestore != null, + hasInfo: onInfo != null, + ); + + switch (result) { + case FileMenuAction.select: + onSelect?.call(); + case FileMenuAction.download: + onDownload?.call(); + case FileMenuAction.openInBrowser: + onOpenInBrowser?.call(); + case FileMenuAction.rename: + onRename?.call(); + case FileMenuAction.move: + onMove?.call(); + case FileMenuAction.copy: + onCopy?.call(); + case FileMenuAction.share: + onShare?.call(); + case FileMenuAction.delete: + onDelete?.call(); + case FileMenuAction.restore: + onRestore?.call(); + case FileMenuAction.info: + onInfo?.call(); + case null: + break; + } + } +} + +class _FileListItemHover extends StatefulWidget { + final FileModel file; + final bool isSelected; + final bool isHighlighted; + final int index; + final bool isDesktop; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool showCheckbox; + final VoidCallback? onSelect; + final bool tapToShowMenu; + + const _FileListItemHover({ + required this.file, + required this.isSelected, + required this.isHighlighted, + required this.index, + required this.isDesktop, + this.onTap, + this.onLongPress, + required this.showCheckbox, + this.onSelect, + this.tapToShowMenu = false, + }); + + @override + State<_FileListItemHover> createState() => _FileListItemHoverState(); +} + +class _FileListItemHoverState extends State<_FileListItemHover> { + bool _isHovered = false; + String? _folderSizeText; + bool _isCalculatingFolder = false; + + Future _calculateFolderSize() async { + setState(() => _isCalculatingFolder = true); + try { + final response = await FileService().getFileInfo( + uri: widget.file.relativePath, + folderSummary: true, + ); + final summary = response['folder_summary']; + if (summary is Map && summary.containsKey('size')) { + if (mounted) { + setState(() { + _folderSizeText = DateUtils.formatFileSize(summary['size'] as int); + _isCalculatingFolder = false; + }); + } + } else { + if (mounted) setState(() => _isCalculatingFolder = false); + } + } catch (_) { + if (mounted) setState(() => _isCalculatingFolder = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + Color bgColor; + if (widget.isSelected) { + bgColor = colorScheme.primary.withValues(alpha: 0.08); + } else if (widget.isHighlighted) { + bgColor = colorScheme.primary.withValues(alpha: 0.06); + } else if (_isHovered) { + bgColor = colorScheme.primary.withValues(alpha: 0.05); + } else if (widget.index.isOdd) { + bgColor = colorScheme.surfaceContainerLow; + } else { + bgColor = colorScheme.surface; + } + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: GestureDetector( + onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap, + onLongPress: widget.onLongPress, + onSecondaryTap: widget.onLongPress, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + margin: const EdgeInsets.symmetric(vertical: 1, horizontal: 4), + padding: EdgeInsets.symmetric( + horizontal: widget.isDesktop ? 24 : 16, + vertical: 8, + ), + child: widget.isDesktop + ? _buildDesktopRow(context) + : _buildMobileRow(context), + ), + ), + ); + } + + Widget _buildSizeCell(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + if (!widget.file.isFolder) { + return Text( + DateUtils.formatFileSize(widget.file.size), + style: TextStyle(fontSize: 13, color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + // 文件夹:已计算 -> 显示大小,未计算 -> 小按钮 + if (_folderSizeText != null) { + return Text( + _folderSizeText!, + style: TextStyle(fontSize: 13, color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + return _buildCalcButton(context, colorScheme); + } + + Widget _buildCalcButton(BuildContext context, ColorScheme colorScheme) { + if (_isCalculatingFolder) { + return SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: colorScheme.primary, + ), + ); + } + + return InkWell( + onTap: _calculateFolderSize, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.calculator, size: 11, color: colorScheme.primary), + const SizedBox(width: 3), + Text( + '计算', + style: TextStyle(fontSize: 11, color: colorScheme.primary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } + + /// 桌面端:三列对齐 Row + Widget _buildDesktopRow(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface; + + return Row( + children: [ + if (widget.showCheckbox) + SizedBox( + width: 40, + child: Checkbox( + value: widget.isSelected, + onChanged: (_) => widget.onSelect?.call(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + FileIconUtils.buildIconWidget(context: context, file: widget.file), + const SizedBox(width: 16), + Expanded( + flex: 5, + child: Text( + widget.file.name, + style: TextStyle( + fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.normal, + fontSize: 14, + color: nameColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 2, + child: Text( + DateUtils.formatDateTime(widget.file.updatedAt), + style: TextStyle(fontSize: 13, color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 1, + child: _buildSizeCell(context), + ), + ], + ); + } + + /// 窄屏端:两行紧凑布局 + Widget _buildMobileRow(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface; + + // 构建第二行内容 + final typeLabel = FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: widget.file.isFolder); + final dateStr = DateUtils.formatDateTime(widget.file.updatedAt); + + return Row( + children: [ + if (widget.showCheckbox) + SizedBox( + width: 40, + child: Checkbox( + value: widget.isSelected, + onChanged: (_) => widget.onSelect?.call(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + FileIconUtils.buildIconWidget(context: context, file: widget.file), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.file.name, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: nameColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // 第二行:文件夹显示 计算按钮,文件显示类型|大小|日期 + if (widget.file.isFolder) + Row( + children: [ + Text('$typeLabel | $dateStr', style: TextStyle(fontSize: 12, color: theme.hintColor)), + const SizedBox(width: 6), + if (_folderSizeText != null) + Text('| $_folderSizeText', style: TextStyle(fontSize: 12, color: theme.hintColor)) + else + _buildCalcButton(context, colorScheme), + ], + ) + else + Text( + '$typeLabel | ${DateUtils.formatFileSize(widget.file.size)} | $dateStr', + style: TextStyle(fontSize: 12, color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/presentation/widgets/file_menu_helper.dart b/lib/presentation/widgets/file_menu_helper.dart new file mode 100644 index 0000000..ad5f23e --- /dev/null +++ b/lib/presentation/widgets/file_menu_helper.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../core/utils/app_logger.dart'; + +/// 文件菜单选项 +enum FileMenuAction { + select, + download, + openInBrowser, + rename, + move, + copy, + share, + info, + delete, + restore, +} + +/// 显示文件菜单 +Future showFileMenu({ + required BuildContext context, + required bool hasSelect, + required bool hasDownload, + required bool hasOpenInBrowser, + required bool hasRename, + required bool hasMove, + required bool hasCopy, + required bool hasShare, + required bool hasDelete, + required bool hasRestore, + bool hasInfo = false, +}) async { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) { + AppLogger.d('showFileMenu: renderBox is null'); + return null; + } + + final offset = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + // 计算菜单位置,居中显示 + final centerX = offset.dx + size.width / 2; + final position = RelativeRect.fromLTRB( + centerX - 120, // 菜单宽度约240,居中显示 + offset.dy + size.height / 2, + centerX + 120, + offset.dy + size.height / 2, + ); + + AppLogger.d('showFileMenu: widget offset: $offset, size: $size, center: $centerX'); + + final result = await showMenu( + context: context, + position: position, + items: >[ + if (hasSelect) + const PopupMenuItem( + value: FileMenuAction.select, + child: Row( + children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 12), + Text('选择'), + ], + ), + ), + if (hasDownload) + const PopupMenuItem( + value: FileMenuAction.download, + child: Row( + children: [ + Icon(Icons.download, size: 20), + SizedBox(width: 12), + Text('下载'), + ], + ), + ), + if (hasOpenInBrowser) + const PopupMenuItem( + value: FileMenuAction.openInBrowser, + child: Row( + children: [ + Icon(Icons.open_in_browser, size: 20), + SizedBox(width: 12), + Text('在浏览器中打开'), + ], + ), + ), + if (hasRename) + const PopupMenuItem( + value: FileMenuAction.rename, + child: Row( + children: [ + Icon(Icons.edit, size: 20), + SizedBox(width: 12), + Text('重命名'), + ], + ), + ), + if (hasMove) + const PopupMenuItem( + value: FileMenuAction.move, + child: Row( + children: [ + Icon(Icons.drive_file_move, size: 20), + SizedBox(width: 12), + Text('移动'), + ], + ), + ), + if (hasCopy) + const PopupMenuItem( + value: FileMenuAction.copy, + child: Row( + children: [ + Icon(Icons.copy, size: 20), + SizedBox(width: 12), + Text('复制'), + ], + ), + ), + if (hasShare) + const PopupMenuItem( + value: FileMenuAction.share, + child: Row( + children: [ + Icon(Icons.share, size: 20), + SizedBox(width: 12), + Text('分享'), + ], + ), + ), + if (hasInfo) + const PopupMenuItem( + value: FileMenuAction.info, + child: Row( + children: [ + Icon(LucideIcons.info, size: 20), + SizedBox(width: 12), + Text('详情'), + ], + ), + ), + if (hasDelete) + const PopupMenuItem( + value: FileMenuAction.delete, + child: Row( + children: [ + Icon(Icons.delete, size: 20, color: Colors.red), + SizedBox(width: 12), + Text('删除', style: TextStyle(color: Colors.red)), + ], + ), + ), + if (hasRestore) + const PopupMenuItem( + value: FileMenuAction.restore, + child: Row( + children: [ + Icon(Icons.restore, size: 20), + SizedBox(width: 12), + Text('恢复'), + ], + ), + ), + ], + ); + + AppLogger.d('showFileMenu: selected value: $result'); + return result; +} diff --git a/lib/presentation/widgets/file_operation_dialogs.dart b/lib/presentation/widgets/file_operation_dialogs.dart new file mode 100644 index 0000000..42a9a9e --- /dev/null +++ b/lib/presentation/widgets/file_operation_dialogs.dart @@ -0,0 +1,598 @@ +import 'package:cloudreve4_flutter/data/models/file_model.dart'; +import 'package:cloudreve4_flutter/services/share_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/file_manager_provider.dart'; +import 'folder_picker.dart'; +import 'glassmorphism_container.dart'; +import 'toast_helper.dart'; + +/// 文件操作对话框工具类 +class FileOperationDialogs { + /// 显示创建文件夹对话框(毛玻璃风格) + static Future showCreateDialog( + BuildContext context, + FileManagerProvider fileManager, + ) async { + final controller = TextEditingController(); + + final confirmed = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '创建文件夹', + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + final screenWidth = MediaQuery.of(context).size.width; + final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0; + return Center( + child: SizedBox( + width: dialogWidth, + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDialogTitle(context, LucideIcons.folderPlus, '创建文件夹'), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: '文件夹名称', + prefixIcon: const Icon(LucideIcons.folder, size: 20), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + autofocus: true, + onSubmitted: (_) => Navigator.of(context).pop(true), + ), + ), + _buildDialogActions( + context, + onCancel: () => Navigator.of(context).pop(false), + onConfirm: () => Navigator.of(context).pop(true), + confirmLabel: '创建', + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + + if (confirmed == true && controller.text.isNotEmpty) { + final error = await fileManager.createFolder(controller.text); + if (error != null && context.mounted) { + ToastHelper.failure('创建文件夹失败: $error'); + } else if (context.mounted) { + ToastHelper.success('文件夹创建成功'); + } + } + } + + /// 显示重命名对话框(毛玻璃风格) + static Future showRenameDialog( + BuildContext context, + FileManagerProvider fileManager, + FileModel file, + ) async { + final controller = TextEditingController(text: file.name); + + final confirmed = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '重命名', + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + final screenWidth = MediaQuery.of(context).size.width; + final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0; + + return Center( + child: SizedBox( + width: dialogWidth, + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDialogTitle(context, LucideIcons.pencil, '重命名'), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: '新名称', + prefixIcon: const Icon(LucideIcons.edit3, size: 20), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + autofocus: true, + onSubmitted: (_) => Navigator.of(context).pop(true), + ), + ), + _buildDialogActions( + context, + onCancel: () => Navigator.of(context).pop(false), + onConfirm: () => Navigator.of(context).pop(true), + confirmLabel: '确定', + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + + if (confirmed == true && controller.text.isNotEmpty) { + await fileManager.renameFile(file.path, controller.text); + } + } + + /// 显示删除确认对话框(多个文件,毛玻璃风格) + static Future showDeleteConfirmation( + BuildContext context, + FileManagerProvider fileManager, + List filePaths, + ) async { + final confirmed = await _showConfirmDialog( + context, + icon: LucideIcons.trash2, + title: '删除确认', + message: '确定删除这 ${filePaths.length} 个文件吗?', + confirmLabel: '删除', + isDestructive: true, + ); + + if (confirmed == true) { + final error = await fileManager.deleteSelectedFiles(); + if (error != null && context.mounted) { + ToastHelper.failure('删除失败: $error'); + } else if (context.mounted) { + ToastHelper.success('删除成功'); + } + } + } + + /// 显示删除确认对话框(单个文件,毛玻璃风格) + static Future showDeleteSingleConfirmation( + BuildContext context, + FileManagerProvider fileManager, + FileModel file, + ) async { + final confirmed = await _showConfirmDialog( + context, + icon: LucideIcons.trash2, + title: '删除确认', + message: '确定删除文件 "${file.name}" 吗?', + confirmLabel: '删除', + isDestructive: true, + ); + + if (confirmed == true) { + final error = await fileManager.deleteFile(file.path); + if (context.mounted) { + if (error != null) { + ToastHelper.failure('删除失败: $error'); + } else { + ToastHelper.success('删除成功'); + } + } + } + } + + /// 显示移动/复制文件对话框 + static Future showMoveDialog( + BuildContext context, + FileManagerProvider fileManager, + FileModel file, + bool copy, + ) async { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(copy ? '复制文件' : '移动文件'), + content: SizedBox( + width: 300, + height: 400, + child: FolderPicker( + currentPath: fileManager.currentPath, + onFolderSelected: (selectedPath) async { + Navigator.of(dialogContext).pop(); + final error = await fileManager.moveFiles( + [file.path], + selectedPath, + copy: copy, + ); + if (context.mounted) { + if (error != null) { + ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error'); + } else { + ToastHelper.success(copy ? '复制成功' : '移动成功'); + } + } + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + ], + ), + ); + } + + /// 显示多选移动/复制文件对话框 + static Future showBatchMoveDialog( + BuildContext context, + FileManagerProvider fileManager, + List uris, + bool copy, + ) async { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(copy ? '复制 ${uris.length} 个文件' : '移动 ${uris.length} 个文件'), + content: SizedBox( + width: 300, + height: 400, + child: FolderPicker( + currentPath: fileManager.currentPath, + onFolderSelected: (selectedPath) async { + Navigator.of(dialogContext).pop(); + final error = await fileManager.moveFiles( + uris, + selectedPath, + copy: copy, + ); + if (context.mounted) { + if (error != null) { + ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error'); + } else { + ToastHelper.success(copy ? '复制成功' : '移动成功'); + } + } + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + ], + ), + ); + } + + /// 显示创建分享对话框 + static Future showShareDialog( + BuildContext context, + FileModel file, + ) async { + final passwordController = TextEditingController(); + final expireDaysController = TextEditingController(text: '7'); + bool isPrivate = true; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setState) { + return AlertDialog( + title: const Text('创建分享'), + content: SizedBox( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration( + labelText: '文件名', + prefixIcon: Icon(Icons.description), + ), + controller: TextEditingController(text: file.name), + enabled: false, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('密码保护'), + subtitle: const Text('需要密码才能访问'), + value: isPrivate, + onChanged: (value) { + setState(() { + isPrivate = value; + }); + }, + ), + if (isPrivate) + TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: '分享密码', + hintText: '留空则自动生成', + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 16), + TextField( + controller: expireDaysController, + decoration: const InputDecoration( + labelText: '有效期(天)', + hintText: '留空则永久有效', + prefixIcon: Icon(Icons.timer), + suffixText: '天', + ), + keyboardType: TextInputType.number, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('创建'), + ), + ], + ); + }, + ), + ); + + if (confirmed == true) { + final expireDaysText = expireDaysController.text.trim(); + final expireDays = expireDaysText.isEmpty + ? null + : int.tryParse(expireDaysText); + final expireSeconds = expireDays != null + ? expireDays * 24 * 60 * 60 + : null; + + try { + final shareUrl = await ShareService().createShare( + uri: file.path, + isPrivate: isPrivate, + password: isPrivate + ? (passwordController.text.isEmpty ? null : passwordController.text) + : null, + expire: expireSeconds, + ); + + if (context.mounted) { + ToastHelper.success('分享创建成功'); + if (context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('分享链接'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shareUrl, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.copy, size: 16), + label: const Text('复制到剪贴板'), + onPressed: () { + Clipboard.setData(ClipboardData(text: shareUrl)); + Navigator.of(dialogContext).pop(); + ToastHelper.success('已复制到剪贴板'); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('关闭'), + ), + ], + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ToastHelper.failure('分享创建失败: $e'); + } + } + } + } + + // ─── 内部工具方法 ─── + + static Widget _buildDialogTitle(BuildContext context, IconData icon, String title) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 12), + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + const SizedBox(width: 10), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ); + } + + static Widget _buildDialogActions( + BuildContext context, { + required VoidCallback onCancel, + required VoidCallback onConfirm, + required String confirmLabel, + bool isDestructive = false, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: onCancel, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: isDestructive ? colorScheme.error : null, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: Text(confirmLabel), + ), + ], + ), + ); + } + + static Future _showConfirmDialog( + BuildContext context, { + required IconData icon, + required String title, + required String message, + required String confirmLabel, + bool isDestructive = false, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: title, + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + final screenWidth = MediaQuery.of(context).size.width; + final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0; + final theme = Theme.of(context); + + return Center( + child: SizedBox( + width: dialogWidth, + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDialogTitle(context, icon, title), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 20), + child: Text(message, style: theme.textTheme.bodyMedium), + ), + _buildDialogActions( + context, + onCancel: () => Navigator.of(context).pop(false), + onConfirm: () => Navigator.of(context).pop(true), + confirmLabel: confirmLabel, + isDestructive: isDestructive, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/presentation/widgets/folder_picker.dart b/lib/presentation/widgets/folder_picker.dart new file mode 100644 index 0000000..38c53ad --- /dev/null +++ b/lib/presentation/widgets/folder_picker.dart @@ -0,0 +1,277 @@ +import 'package:cloudreve4_flutter/data/models/file_model.dart'; +import 'package:cloudreve4_flutter/services/file_service.dart'; +import 'package:flutter/material.dart'; +import '../../core/utils/app_logger.dart'; + +/// 文件夹选择器 +class FolderPicker extends StatefulWidget { + final String currentPath; + final void Function(String path) onFolderSelected; + final int? maxVisibleItems; + + const FolderPicker({ + super.key, + required this.currentPath, + required this.onFolderSelected, + this.maxVisibleItems, + }); + + @override + State createState() => _FolderPickerState(); +} + +class _FolderPickerState extends State { + String _currentPath = '/'; + List _folders = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _currentPath = widget.currentPath; + _loadFolders(); + } + + Future _loadFolders() async { + setState(() { + _isLoading = true; + }); + + try { + final response = await FileService().listFiles( + uri: _currentPath, + pageSize: 100, + ); + + final List filesData = response['files'] as List? ?? []; + setState(() { + _folders = filesData + .map((f) => FileModel.fromJson(f as Map)) + .where((f) => f.isFolder) + .toList(); + }); + } catch (e) { + AppLogger.d('加载文件夹失败: $e'); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _enterFolder(FileModel folder) { + setState(() { + _currentPath = folder.path; + }); + _loadFolders(); + } + + @override + Widget build(BuildContext context) { + final primaryColor = Theme.of(context).colorScheme.primary; + final maxHeight = MediaQuery.of(context).size.height * 0.5; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildBreadcrumb(context, primaryColor), + const Divider(height: 1), + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: widget.maxVisibleItems != null + ? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight) + : maxHeight, + ), + child: _buildListContent(context, primaryColor), + ), + ), + ], + ); + } + + Widget _buildListContent(BuildContext context, Color primaryColor) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } + + if (_folders.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < _folders.length; i++) ...[ + if (i > 0) const Divider(height: 1, indent: 56, endIndent: 16), + _buildFolderItem(_folders[i], primaryColor), + ], + ], + ), + ); + } + + Widget _buildFolderItem(FileModel folder, Color primaryColor) { + return InkWell( + onTap: () => _enterFolder(folder), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.folder, color: primaryColor, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)), + ), + Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20), + ], + ), + ), + ); + } + + Widget _buildBreadcrumb(BuildContext context, Color primaryColor) { + final pathParts = _currentPath.split('/'); + pathParts.removeWhere((part) => part.isEmpty); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildBreadcrumbItem( + context, + name: '首页', + path: '/', + isLast: pathParts.isEmpty, + primaryColor: primaryColor, + ), + ...pathParts.asMap().entries.expand((entry) { + final index = entry.key; + final part = entry.value; + final path = '/${pathParts.sublist(0, index + 1).join('/')}'; + + return [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.chevron_right, + size: 16, + color: Colors.grey.shade400, + ), + ), + _buildBreadcrumbItem( + context, + name: part, + path: path, + isLast: index == pathParts.length - 1, + primaryColor: primaryColor, + ), + ]; + }), + ], + ), + ), + ), + const SizedBox(width: 12), + FilledButton.tonal( + onPressed: () { + final relative = _currentPath.startsWith('cloudreve://my') + ? _currentPath.replaceFirst('cloudreve://my', '') + : _currentPath; + widget.onFolderSelected(relative.isEmpty ? '/' : relative); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('选择'), + ), + ], + ), + ), + const Divider(height: 1), + ], + ); + } + + Widget _buildBreadcrumbItem( + BuildContext context, { + required String name, + required String path, + required bool isLast, + required Color primaryColor, + }) { + return InkWell( + onTap: isLast + ? null + : () { + setState(() { + _currentPath = path; + }); + _loadFolders(); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isLast + ? primaryColor.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (name == '首页') + Icon( + Icons.home_filled, + size: 16, + color: isLast ? primaryColor : Colors.grey.shade600, + ), + if (name == '首页') const SizedBox(width: 6), + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: isLast ? FontWeight.w600 : FontWeight.w500, + color: isLast ? primaryColor : Colors.grey.shade700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/gesture_handler_mixin.dart b/lib/presentation/widgets/gesture_handler_mixin.dart new file mode 100644 index 0000000..c4d67f5 --- /dev/null +++ b/lib/presentation/widgets/gesture_handler_mixin.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; +import '../providers/file_manager_provider.dart'; +import 'exit_hint_overlay.dart'; +import '../../services/desktop_service.dart'; + +/// 手势处理 Mixin +mixin GestureHandlerMixin on State { + DateTime? _lastSwipeTime; + final ExitHintOverlay _exitHintOverlay = ExitHintOverlay(); + + /// 处理滑动手势 + void handleSwipe( + BuildContext context, + FileManagerProvider fileManager, + DragEndDetails details, + ) { + if (details.primaryVelocity == null) return; + + // 从右往左滑(velocity < 0):返回或退出 + if (details.primaryVelocity! < 0) { + if (fileManager.currentPath == '/') { + checkExitApp(context); + } else { + navigateBack(context, fileManager); + } + } + } + + /// 处理返回键 + Future handleBackPress( + BuildContext context, + FileManagerProvider fileManager, + ) async { + if (fileManager.currentPath == '/') { + await checkExitApp(context); + } else { + await navigateBack(context, fileManager); + } + } + + /// 返回上一级 + Future navigateBack( + BuildContext context, + FileManagerProvider fileManager, + ) async { + await fileManager.goBack(); + } + + /// 检查退出应用 + Future checkExitApp(BuildContext context) async { + final now = DateTime.now(); + if (_lastSwipeTime != null && now.difference(_lastSwipeTime!).inSeconds < 2) { + _exitHintOverlay.remove(); + if (DesktopService.isDesktopPlatform) { + await windowManager.hide(); + } else { + SystemNavigator.pop(); + } + } else { + _lastSwipeTime = now; + _exitHintOverlay.show(context); + Future.delayed(const Duration(seconds: 2), () { + _exitHintOverlay.remove(); + }); + } + } +} diff --git a/lib/presentation/widgets/glassmorphism_container.dart b/lib/presentation/widgets/glassmorphism_container.dart new file mode 100644 index 0000000..cce2eb1 --- /dev/null +++ b/lib/presentation/widgets/glassmorphism_container.dart @@ -0,0 +1,46 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class GlassmorphismContainer extends StatelessWidget { + final Widget child; + final double borderRadius; + final double sigmaX; + final double sigmaY; + final EdgeInsetsGeometry? padding; + + const GlassmorphismContainer({ + super.key, + required this.child, + this.borderRadius = 12, + this.sigmaX = 10, + this.sigmaY = 10, + this.padding, + }); + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + final bgColor = isLight + ? Colors.white.withValues(alpha: 0.7) + : Colors.black.withValues(alpha: 0.3); + final borderColor = isLight + ? Colors.white.withValues(alpha: 0.3) + : Colors.white.withValues(alpha: 0.1); + + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigmaX, sigmaY: sigmaY), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: borderColor), + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/search_dialog.dart b/lib/presentation/widgets/search_dialog.dart new file mode 100644 index 0000000..3655f7a --- /dev/null +++ b/lib/presentation/widgets/search_dialog.dart @@ -0,0 +1,445 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../core/utils/date_utils.dart' as date_utils; +import '../../core/utils/file_icon_utils.dart'; +import '../../data/models/file_model.dart'; +import '../../services/file_service.dart'; +import '../../services/storage_service.dart'; +import '../providers/file_manager_provider.dart'; +import '../providers/navigation_provider.dart'; +import 'glassmorphism_container.dart'; + +/// 搜索对话框 +class SearchDialog extends StatefulWidget { + const SearchDialog({super.key}); + + static Future show(BuildContext context) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '搜索', + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(), + ); + } + + @override + State createState() => _SearchDialogState(); +} + +class _SearchDialogState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + + List _files = []; + List _searchHistory = []; + bool _isLoading = false; + String? _errorMessage; + bool _caseFolding = false; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _loadHistory(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _searchFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + _searchFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadHistory() async { + final history = await StorageService.instance.getSearchHistory(); + if (mounted) setState(() => _searchHistory = history); + } + + void _onSearchChanged(String value) { + _debounceTimer?.cancel(); + if (value.trim().isEmpty) { + setState(() { + _files = []; + _isLoading = false; + _errorMessage = null; + }); + return; + } + setState(() => _isLoading = true); + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + _performSearch(value.trim()); + }); + } + + Future _performSearch(String query) async { + if (query.isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await FileService().searchFiles( + name: query, + caseFolding: _caseFolding, + ); + final filesData = response['files'] as List? ?? []; + final files = filesData + .map((f) => FileModel.fromJson(f as Map)) + .toList(); + + if (!mounted) return; + setState(() { + _files = files; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + + void _toggleCaseFolding() { + setState(() => _caseFolding = !_caseFolding); + final query = _searchController.text.trim(); + if (query.isNotEmpty) _performSearch(query); + } + + Future _clearHistory() async { + await StorageService.instance.clearSearchHistory(); + if (mounted) setState(() => _searchHistory = []); + } + + Future _onResultTap(FileModel file) async { + final query = _searchController.text.trim(); + + // 在 async gap 之前获取所有引用 + final navProvider = Provider.of(context, listen: false); + final fileManager = + Provider.of(context, listen: false); + final parentPath = _extractParentPath(file.path); + final filePath = file.path; + + if (query.isNotEmpty) { + await StorageService.instance.addToSearchHistory(query); + } + + if (parentPath == null || !mounted) return; + + Navigator.of(context).pop(); + + navProvider.setIndex(1); + fileManager.navigateAndHighlight(parentPath, filePath); + } + + String? _extractParentPath(String filePath) { + const prefix = 'cloudreve://my'; + if (!filePath.startsWith(prefix)) return null; + + final relativePath = filePath.substring(prefix.length); + if (relativePath.isEmpty) return null; + + final parts = relativePath.split('/'); + if (parts.length > 1) parts.removeLast(); + return parts.isEmpty ? '/' : parts.join('/'); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final isWide = screenWidth >= 600; + final dialogWidth = isWide ? 560.0 : screenWidth - 32.0; + + return Center( + child: Container( + width: dialogWidth, + constraints: BoxConstraints(maxHeight: screenHeight * 0.75), + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSearchInput(context), + const Divider(height: 1), + Flexible(child: _buildBody(context)), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSearchInput(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 12), + child: Row( + children: [ + Icon(LucideIcons.search, size: 20, color: theme.hintColor), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: '搜索文件...', + border: InputBorder.none, + hintStyle: TextStyle(color: theme.hintColor), + ), + style: theme.textTheme.bodyLarge, + textInputAction: TextInputAction.search, + onChanged: _onSearchChanged, + onSubmitted: (value) { + _debounceTimer?.cancel(); + _performSearch(value.trim()); + }, + ), + ), + IconButton( + icon: Icon( + LucideIcons.caseSensitive, + size: 18, + color: _caseFolding ? colorScheme.primary : theme.hintColor, + ), + onPressed: _toggleCaseFolding, + tooltip: '忽略大小写', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(LucideIcons.x, size: 18, color: theme.hintColor), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ); + } + + Widget _buildBody(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + if (_isLoading && _files.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (_errorMessage != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.alertCircle, + size: 40, color: colorScheme.error.withValues(alpha: 0.7)), + const SizedBox(height: 8), + Text(_errorMessage!, + style: TextStyle(color: theme.hintColor), + textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: () => + _performSearch(_searchController.text.trim()), + child: const Text('重试'), + ), + ], + ), + ), + ); + } + + if (_files.isEmpty) { + final query = _searchController.text.trim(); + if (query.isEmpty && _searchHistory.isNotEmpty) { + return _buildHistorySection(context); + } + if (query.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.searchX, + size: 40, + color: theme.hintColor.withValues(alpha: 0.5)), + const SizedBox(height: 8), + Text('未找到匹配文件', + style: TextStyle(color: theme.hintColor)), + ], + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text('输入关键词搜索文件', + style: TextStyle(color: theme.hintColor)), + ), + ); + } + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.separated( + controller: _scrollController, + shrinkWrap: true, + itemCount: _files.length, + separatorBuilder: (context, index) => + Divider(height: 1, indent: 68, endIndent: 20), + itemBuilder: (context, index) => + _buildResultItem(context, _files[index]), + ), + ); + } + + Widget _buildHistorySection(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(LucideIcons.history, size: 16, color: theme.hintColor), + const SizedBox(width: 6), + Text('搜索历史', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.hintColor)), + const Spacer(), + GestureDetector( + onTap: _clearHistory, + child: Text('清除', + style: + TextStyle(fontSize: 12, color: colorScheme.primary)), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: _searchHistory.map((query) { + return ActionChip( + label: Text(query, style: const TextStyle(fontSize: 13)), + onPressed: () { + _searchController.text = query; + _onSearchChanged(query); + }, + avatar: Icon(LucideIcons.search, size: 14), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildResultItem(BuildContext context, FileModel file) { + final theme = Theme.of(context); + + return InkWell( + onTap: () => _onResultTap(file), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + FileIconUtils.buildIconWidget(context: context, file: file), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.name, + style: const TextStyle( + fontWeight: FontWeight.w500, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + Uri.decodeComponent(file.relativePath), + style: TextStyle(fontSize: 12, color: theme.hintColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + file.isFolder + ? '文件夹' + : date_utils.DateUtils.formatFileSize(file.size), + style: TextStyle(fontSize: 12, color: theme.hintColor), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/selection_toolbar.dart b/lib/presentation/widgets/selection_toolbar.dart new file mode 100644 index 0000000..335ce97 --- /dev/null +++ b/lib/presentation/widgets/selection_toolbar.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// 选择工具栏组件 +class SelectionToolbar extends StatelessWidget { + final int selectionCount; + final VoidCallback onCancel; + final VoidCallback? onRename; + final VoidCallback? onMove; + final VoidCallback? onCopy; + final VoidCallback onDelete; + + const SelectionToolbar({ + super.key, + required this.selectionCount, + required this.onCancel, + this.onRename, + this.onMove, + this.onCopy, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Text( + '已选择 $selectionCount 项', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.cancel), + onPressed: onCancel, + tooltip: '取消选择', + ), + if (selectionCount == 1 && onRename != null) + IconButton( + icon: const Icon(Icons.edit), + onPressed: onRename, + tooltip: '重命名', + ), + if (onMove != null) + IconButton( + icon: const Icon(Icons.drive_file_move_outline), + onPressed: onMove, + tooltip: '移动', + ), + if (onCopy != null) + IconButton( + icon: const Icon(Icons.content_copy), + onPressed: onCopy, + tooltip: '复制', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + tooltip: '删除', + color: Colors.red, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/share_list_item.dart b/lib/presentation/widgets/share_list_item.dart new file mode 100644 index 0000000..180271b --- /dev/null +++ b/lib/presentation/widgets/share_list_item.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import '../../data/models/share_model.dart'; + +/// 截断文件名,智能截断避免多个小数点 +String _truncateFileName(String name, int maxLength) { + if (name.length <= maxLength) { + return name; + } + + // 查找最后一个点 + final lastDotIndex = name.lastIndexOf('.'); + + // 如果没有点,或者点在开头或末尾,直接截断 + if (lastDotIndex <= 0 || lastDotIndex >= name.length - 1) { + return '${name.substring(0, maxLength - 3)}...'; + } + + // 有扩展名的情况 + final extension = name.substring(lastDotIndex); + final nameWithoutExt = name.substring(0, lastDotIndex); + + // 扩展名太长,直接截断整个文件名 + if (extension.length >= maxLength) { + return '${name.substring(0, maxLength - 3)}...'; + } + + // 计算主体部分能显示的长度 + const ellipsis = '...'; + final maxNameLength = maxLength - extension.length - ellipsis.length; + + // 空间太小,主体部分至少保留3个字符 + if (maxNameLength < 3) { + return '${name.substring(0, maxLength - 3)}...'; + } + + // 主体部分能完整显示 + if (nameWithoutExt.length <= maxNameLength) { + return '$nameWithoutExt$ellipsis$extension'; + } + + // 主体部分需要截断 + return '${nameWithoutExt.substring(0, maxNameLength)}$ellipsis$extension'; +} + +/// 文件名显示组件 +class _FileNameDisplay extends StatelessWidget { + final String name; + final Color? textColor; + + const _FileNameDisplay({ + required this.name, + this.textColor, + }); + + /// 精确计算文本宽度 + String _fitText(String text, double maxWidth, TextStyle style) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + + if (textPainter.width <= maxWidth) { + return text; + } + + // 二分查找最佳长度 + int min = 1; + int max = text.length; + int best = 0; + + while (min <= max) { + final mid = ((min + max) / 2).floor(); + final candidate = _truncateFileName(text, mid); + + final painter = TextPainter( + text: TextSpan(text: candidate, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + ); + painter.layout(); + + if (painter.width <= maxWidth) { + best = mid; + min = mid + 1; + } else { + max = mid - 1; + } + } + + return _truncateFileName(text, best); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final style = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: textColor, + ); + + final fittedName = _fitText(name, maxWidth, style); + + return Text( + fittedName, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } +} + +/// 分享列表项 +class ShareListItem extends StatelessWidget { + final ShareModel share; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + const ShareListItem({ + super.key, + required this.share, + this.onEdit, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final expired = share.expired; + final textColor = expired ? Colors.grey.shade600 : null; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: onEdit, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + flex: 65, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + share.isFolder + ? Icons.folder + : Icons.insert_drive_file_outlined, + color: textColor, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: _FileNameDisplay( + name: share.name, + textColor: textColor, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + share.url, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Expanded( + flex: 35, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (share.isPrivate ?? false) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock, size: 14, color: Colors.blue), + ], + ), + ), + ], + if (share.downloaded != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${share.downloaded}', + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + Icon(Icons.download, size: 14, color: Colors.green.shade700), + ], + ), + ), + + ], + ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${share.visited}', + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + Icon(Icons.visibility, size: 14, color: Colors.yellow.shade700), + ], + ), + ), + ], + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (onEdit != null) ...[ + IconButton( + icon: const Icon(Icons.edit, size: 24), + onPressed: onEdit, + tooltip: '编辑', + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + if (onDelete != null) ...[ + IconButton( + icon: const Icon(Icons.delete, size: 24), + onPressed: onDelete, + tooltip: '删除', + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + style: IconButton.styleFrom( + foregroundColor: Colors.red.shade700, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/thumbnail_image.dart b/lib/presentation/widgets/thumbnail_image.dart new file mode 100644 index 0000000..1e93b42 --- /dev/null +++ b/lib/presentation/widgets/thumbnail_image.dart @@ -0,0 +1,92 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import '../../data/models/file_model.dart'; +import '../../services/cache_manager_service.dart'; +import '../../services/thumbnail_service.dart'; +import '../../core/utils/file_icon_utils.dart'; + +/// 缩略图加载组件 — 异步获取 URL 并用 CachedNetworkImage 渲染 +class ThumbnailImage extends StatefulWidget { + final FileModel file; + final String? contextHint; + final double borderRadius; + + const ThumbnailImage({ + super.key, + required this.file, + this.contextHint, + this.borderRadius = 10, + }); + + @override + State createState() => _ThumbnailImageState(); +} + +class _ThumbnailImageState extends State { + String? _imageUrl; + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _loadThumbnail(); + } + + @override + void didUpdateWidget(ThumbnailImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.path != widget.file.path) { + _loadThumbnail(); + } + } + + Future _loadThumbnail() async { + setState(() { + _isLoading = true; + _hasError = false; + }); + + final url = await ThumbnailService.instance.getThumbnailUrl( + fileUri: widget.file.relativePath, + contextHint: widget.contextHint, + ); + + if (!mounted) return; + + setState(() { + _imageUrl = url; + _isLoading = false; + _hasError = url == null; + }); + } + + Widget _buildPlaceholder(BuildContext context) { + return Center( + child: FileIconUtils.buildIconWidget( + context: context, + file: widget.file, + size: 40, + iconSize: 22, + borderRadius: widget.borderRadius, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) return _buildPlaceholder(context); + if (_hasError || _imageUrl == null) return _buildPlaceholder(context); + + return ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: CachedNetworkImage( + imageUrl: _imageUrl!, + cacheManager: CacheManagerService.instance.manager, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(context), + errorWidget: (context, url, error) => _buildPlaceholder(context), + ), + ); + } +} diff --git a/lib/presentation/widgets/toast_helper.dart b/lib/presentation/widgets/toast_helper.dart new file mode 100644 index 0000000..494b6cf --- /dev/null +++ b/lib/presentation/widgets/toast_helper.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:oktoast/oktoast.dart'; + +/// Toast 封装组件 +class ToastHelper { + ToastHelper._(); + + /// 显示成功提示 + static void success(String message) { + _showToast( + message, + backgroundColor: const Color(0xFFE8F5E9), + textColor: const Color(0xFF2E7D32), + ); + } + + /// 显示失败提示 + static void failure(String message) { + _showToast( + message, + backgroundColor: const Color(0xFFFFEBEE), + textColor: const Color(0xFFC62828), + ); + } + + /// 显示错误提示 + static void error(String message) { + _showToast( + message, + backgroundColor: const Color(0xFFFFF3E0), + textColor: const Color(0xFFEF6C00), + duration: const Duration(seconds: 3), + ); + } + + /// 显示警告提示 + static void warning(String message) { + _showToast( + message, + backgroundColor: const Color(0xFFFFF8E1), + textColor: const Color(0xFFFF8F00), + ); + } + + /// 显示信息提示 + static void info(String message) { + _showToast( + message, + backgroundColor: const Color(0xFFE3F2FD), + textColor: const Color(0xFF1565C0), + ); + } + + static void _showToast( + String message, { + required Color backgroundColor, + required Color textColor, + Duration duration = const Duration(seconds: 2), + }) { + showToastWidget( + _buildToast(message: message, backgroundColor: backgroundColor, textColor: textColor), + duration: duration, + position: ToastPosition.bottom, + ); + } + + static Widget _buildToast({ + required String message, + required Color backgroundColor, + required Color textColor, + }) { + return Builder(builder: (context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: textColor.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + message, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + fontFamily: theme.textTheme.bodyMedium?.fontFamily, + ), + ), + ); + }); + } + + /// 显示自定义图标提示 + static void showWithIcon({ + required String message, + required IconData icon, + required Color iconColor, + required Color backgroundColor, + required Color textColor, + Duration duration = const Duration(seconds: 2), + }) { + showToastWidget( + _buildIconToast( + message: message, + icon: icon, + iconColor: iconColor, + backgroundColor: backgroundColor, + textColor: textColor, + ), + duration: duration, + position: ToastPosition.bottom, + ); + } + + static Widget _buildIconToast({ + required String message, + required IconData icon, + required Color iconColor, + required Color backgroundColor, + required Color textColor, + }) { + return Builder(builder: (context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: iconColor.withValues(alpha: 0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 10), + Flexible( + child: Text( + message, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + fontFamily: theme.textTheme.bodyMedium?.fontFamily, + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/presentation/widgets/upload_dialog.dart b/lib/presentation/widgets/upload_dialog.dart new file mode 100644 index 0000000..3062e61 --- /dev/null +++ b/lib/presentation/widgets/upload_dialog.dart @@ -0,0 +1,276 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import '../providers/upload_manager_provider.dart'; +import '../providers/file_manager_provider.dart'; +import '../providers/navigation_provider.dart'; +import 'glassmorphism_container.dart'; +import 'toast_helper.dart'; + +/// 显示上传对话框(毛玻璃风格) +void showUploadDialog(BuildContext context) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '上传', + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ).drive(Tween(begin: 0.92, end: 1.0)); + final fadeAnim = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ).drive(Tween(begin: 0.0, end: 1.0)); + return ScaleTransition( + scale: scaleAnim, + child: FadeTransition(opacity: fadeAnim, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => + const _UploadDialogContent(), + ); +} + +class _UploadDialogContent extends StatelessWidget { + const _UploadDialogContent(); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final dialogWidth = screenWidth >= 600 ? 420.0 : screenWidth - 48.0; + + return Center( + child: SizedBox( + width: dialogWidth, + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: _buildFileSelectionButtons(context), + ), + const Divider(height: 1), + _buildViewTasksButton(context), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 12), + child: Row( + children: [ + Icon(LucideIcons.uploadCloud, size: 20, color: theme.hintColor), + const SizedBox(width: 10), + Text( + '选择要上传的文件', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ); + } + + Widget _buildFileSelectionButtons(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildUploadOption( + context, + icon: LucideIcons.image, + label: '选择图片', + description: 'JPG, PNG, GIF, WebP 等', + color: Colors.purple.shade400, + onTap: () => _pickFiles(context, FileType.image), + ), + const SizedBox(height: 12), + _buildUploadOption( + context, + icon: LucideIcons.video, + label: '选择视频', + description: 'MP4, AVI, MKV 等', + color: Colors.orange.shade400, + onTap: () => _pickFiles(context, FileType.video), + ), + const SizedBox(height: 12), + _buildUploadOption( + context, + icon: LucideIcons.file, + label: '选择所有文件', + description: '任意类型文件', + color: colorScheme.primary, + onTap: () => _pickFiles(context, FileType.any), + ), + ], + ); + } + + Widget _buildUploadOption( + BuildContext context, { + required IconData icon, + required String label, + required String description, + required Color color, + required VoidCallback onTap, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.06), + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: color), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + ], + ), + ), + Icon(LucideIcons.chevronRight, size: 18, color: Theme.of(context).hintColor), + ], + ), + ), + ); + } + + Widget _buildViewTasksButton(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InkWell( + onTap: () { + Navigator.of(context).pop(); + Provider.of(context, listen: false).setIndex(2); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.list, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + '查看上传任务', + style: TextStyle( + fontWeight: FontWeight.w500, + color: colorScheme.primary, + ), + ), + ], + ), + ), + ); + } + + Future _pickFiles(BuildContext context, FileType type) async { + try { + final result = await FilePicker.platform.pickFiles( + type: type, + allowMultiple: true, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + + if (!context.mounted) return; + if (result == null || result.files.isEmpty) { + ToastHelper.warning('未选择文件'); + return; + } + + final files = []; + for (final file in result.files) { + if (file.path != null) { + files.add(File(file.path!)); + } + } + + if (files.isEmpty) { + if (!context.mounted) return; + ToastHelper.error('无法获取文件路径'); + return; + } + + final uploadManager = Provider.of( + context, + listen: false, + ); + final fileManager = Provider.of( + context, + listen: false, + ); + + uploadManager.markShouldShowDialog(); + await uploadManager.startUpload(files, fileManager.currentPath); + + if (context.mounted) { + ToastHelper.info('上传已开始,查看任务页'); + } + } catch (e) { + if (!context.mounted) return; + ToastHelper.failure('选择文件失败: $e'); + } + } +} diff --git a/lib/presentation/widgets/upload_progress_dialog.dart b/lib/presentation/widgets/upload_progress_dialog.dart new file mode 100644 index 0000000..36ac762 --- /dev/null +++ b/lib/presentation/widgets/upload_progress_dialog.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../data/models/upload_task_model.dart'; +import '../../services/upload_service.dart'; +import '../providers/upload_manager_provider.dart'; +import 'upload_progress_item.dart'; + +/// 上传进度对话框 +class UploadProgressDialog extends StatelessWidget { + const UploadProgressDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, uploadService, child) { + final tasks = uploadService.allTasks; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 300, + // 当没有任务时,最小高度约为2个任务的大小(约160px) + minHeight: tasks.isEmpty ? 160 : 0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 标题栏 + _buildHeader(context, uploadService, tasks), + + // 任务列表 + Flexible( + child: tasks.isEmpty + ? _buildEmptyState(context) + : _buildTaskList(context, uploadService, tasks), + ), + + // 底部操作栏 + _buildFooter(context, uploadService, tasks), + ], + ), + ), + ); + }, + ); + } + + Widget _buildEmptyState(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_upload, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + '暂无上传任务', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildTaskList( + BuildContext context, + UploadService uploadService, + List tasks, + ) { + return ListView.separated( + shrinkWrap: true, + itemCount: tasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final task = tasks[index]; + return UploadProgressItem( + key: ValueKey(task.id), + task: task, + onPause: () => uploadService.cancelUpload(task.id), + onResume: () => uploadService.retryUpload(task.id), + onCancel: () => uploadService.cancelUpload(task.id), + onDelete: () async { + uploadService.removeTask(task.id); + }, + onRetry: () => uploadService.retryUpload(task.id), + ); + }, + ); + } + + Widget _buildHeader( + BuildContext context, + UploadService uploadService, + List tasks, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + const Icon(Icons.cloud_upload, color: Colors.blue), + const SizedBox(width: 12), + const Text( + '上传任务', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Consumer( + builder: (context, uploadService, child) { + final uploadingCount = uploadService.activeTasks.length; + if (uploadingCount > 0) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '上传中: $uploadingCount', + style: const TextStyle( + fontSize: 12, + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + final uploadManager = Provider.of( + context, + listen: false, + ); + uploadManager.hideDialog(); + Navigator.of(context).pop(); + }, + tooltip: '关闭', + ), + ], + ), + ); + } + + Widget _buildFooter( + BuildContext context, + UploadService uploadService, + List tasks, + ) { + final hasCompleted = tasks.any((t) => t.status == UploadStatus.completed); + final hasFailed = tasks.any((t) => t.status == UploadStatus.failed); + final hasCancelled = tasks.any((t) => t.status == UploadStatus.cancelled); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + if (hasCompleted || hasCancelled) + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('清除已完成'), + onPressed: () async { + uploadService.clearCompletedTasks(); + }, + ), + if (hasFailed) + TextButton.icon( + icon: const Icon(Icons.clear_all, size: 18), + label: const Text('清除失败'), + onPressed: () { + uploadService.clearFailedTasks(); + }, + ), + const Spacer(), + FilledButton( + onPressed: () { + final uploadManager = Provider.of( + context, + listen: false, + ); + uploadManager.hideDialog(); + Navigator.of(context).pop(); + }, + child: const Text('关闭'), + ), + ], + ), + ); + } +} + +/// 显示上传对话框 +void showUploadDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const UploadProgressDialog(), + barrierDismissible: false, + ); +} diff --git a/lib/presentation/widgets/upload_progress_item.dart b/lib/presentation/widgets/upload_progress_item.dart new file mode 100644 index 0000000..57cb52e --- /dev/null +++ b/lib/presentation/widgets/upload_progress_item.dart @@ -0,0 +1,336 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../data/models/upload_task_model.dart'; + +/// 上传任务列表项 +class UploadProgressItem extends StatelessWidget { + final UploadTaskModel task; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onCancel; + final VoidCallback? onDelete; + final VoidCallback? onRetry; + final VoidCallback? onNavigate; + + const UploadProgressItem({ + super.key, + required this.task, + this.onPause, + this.onResume, + this.onCancel, + this.onDelete, + this.onRetry, + this.onNavigate, + }); + + @override + Widget build(BuildContext context) { + final isUploading = task.status == UploadStatus.uploading; + final isWaiting = task.status == UploadStatus.waiting; + final isPaused = task.status == UploadStatus.paused; + final isFailed = task.status == UploadStatus.failed; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: _getCardColor(context, task.status), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getBorderColor(context, task.status), + ), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildStatusIcon(context, task.status), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.fileName, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + _buildStatusRow(context, task), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: _buildActionButtons(context, task), + ), + ], + ), + if (isUploading || isWaiting || isPaused) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: isPaused ? null : task.progress, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Text( + isPaused ? '已暂停' : task.progressText, + style: const TextStyle(fontSize: 12), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '${task.uploadedBytes}/${task.fileSize} bytes', + style: const TextStyle(fontSize: 12), + ), + const Spacer(), + if (isUploading && task.speedText.isNotEmpty) ...[ + Text( + task.speedText, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + ], + Text( + task.readableFileSize, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ] else if (isFailed && task.errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + task.errorMessage!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatusIcon(BuildContext context, UploadStatus status) { + final color = _getStatusColor(status); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _getStatusIcon(status), + size: 18, + color: color, + ), + ); + } + + Widget _buildStatusRow(BuildContext context, UploadTaskModel task) { + final color = _getStatusColor(task.status); + final isCompleted = task.status == UploadStatus.completed; + + return Row( + children: [ + Text( + task.statusText, + style: TextStyle(fontSize: 12, color: color), + ), + if (isCompleted) ...[ + Text( + ' · ', + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + task.readableFileSize, + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + ' · ', + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + Text( + _formatDateTime(task.completedAt!), + style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), + ), + ], + ], + ); + } + + List _buildActionButtons( + BuildContext context, + UploadTaskModel task, + ) { + final errorColor = Theme.of(context).colorScheme.error; + + switch (task.status) { + case UploadStatus.waiting: + case UploadStatus.uploading: + return [ + IconButton( + icon: const Icon(Icons.pause, size: 20), + onPressed: onPause, + tooltip: '暂停', + ), + ]; + case UploadStatus.paused: + return [ + IconButton( + icon: const Icon(Icons.play_arrow, size: 20), + onPressed: onResume, + tooltip: '继续', + ), + IconButton( + icon: Icon(Icons.cancel, size: 20, color: errorColor), + onPressed: onCancel, + tooltip: '取消', + ), + ]; + case UploadStatus.failed: + return [ + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: onRetry, + tooltip: '重试', + ), + IconButton( + icon: Icon(Icons.delete, size: 20, color: errorColor), + onPressed: onDelete, + tooltip: '删除', + ), + ]; + case UploadStatus.completed: + return [ + if (onNavigate != null) + IconButton( + icon: const Icon(LucideIcons.folderOpen, size: 20), + onPressed: onNavigate, + tooltip: '打开文件夹', + ), + IconButton( + icon: Icon(Icons.delete_outline, size: 20, color: errorColor), + onPressed: onDelete, + tooltip: '删除', + ), + ]; + case UploadStatus.cancelled: + return [ + IconButton( + icon: Icon(Icons.delete, size: 20, color: errorColor), + onPressed: onDelete, + tooltip: '删除', + ), + ]; + } + } + + IconData _getStatusIcon(UploadStatus status) { + switch (status) { + case UploadStatus.waiting: + return LucideIcons.clock; + case UploadStatus.uploading: + return LucideIcons.upload; + case UploadStatus.completed: + return LucideIcons.checkCircle2; + case UploadStatus.paused: + return LucideIcons.pause; + case UploadStatus.failed: + case UploadStatus.cancelled: + return LucideIcons.xCircle; + } + } + + Color _getStatusColor(UploadStatus status) { + switch (status) { + case UploadStatus.waiting: + return Colors.orange; + case UploadStatus.uploading: + return Colors.blue; + case UploadStatus.completed: + return Colors.green; + case UploadStatus.paused: + return Colors.orange; + case UploadStatus.failed: + case UploadStatus.cancelled: + return Colors.red; + } + } + + Color _getCardColor(BuildContext context, UploadStatus status) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (status) { + case UploadStatus.completed: + return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05); + case UploadStatus.failed: + case UploadStatus.cancelled: + return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05); + default: + return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6); + } + } + + Color _getBorderColor(BuildContext context, UploadStatus status) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (status) { + case UploadStatus.completed: + return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15); + case UploadStatus.failed: + case UploadStatus.cancelled: + return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15); + default: + return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3); + } + } + + String _formatDateTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return '刚刚'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}分钟前'; + } else if (difference.inHours < 24) { + return '${difference.inHours}小时前'; + } else { + return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/presentation/widgets/user_avatar.dart b/lib/presentation/widgets/user_avatar.dart new file mode 100644 index 0000000..7486de8 --- /dev/null +++ b/lib/presentation/widgets/user_avatar.dart @@ -0,0 +1,131 @@ +import 'dart:io'; +import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart'; +import 'package:cloudreve4_flutter/services/avatar_cache_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// 用户头像组件 +/// 1. 缓存存在 → 直接 FileImage 显示 +/// 2. 缓存不存在 → 默认显示首字母 fallback,异步 getAvatar,成功后更新 UI +class UserAvatar extends StatefulWidget { + final String userId; + final String? email; + final String displayName; + final double radius; + final String? avatarType; + + const UserAvatar({ + super.key, + required this.userId, + this.email, + required this.displayName, + this.radius = 24, + this.avatarType, + }); + + @override + State createState() => _UserAvatarState(); + + /// 清除指定用户的头像缓存(头像上传/切换后调用) + static Future evictCache(String userId) async { + await AvatarCacheService.instance.evictCache(userId); + } + + /// 清除所有头像缓存(登出时调用) + static Future clearAllCache() async { + await AvatarCacheService.instance.clearAllCache(); + } +} + +class _UserAvatarState extends State { + File? _avatarFile; + bool _loadTriggered = false; + + String get _initial { + final name = widget.displayName; + return name.isNotEmpty ? name[0].toUpperCase() : 'U'; + } + + @override + void initState() { + super.initState(); + _checkCache(); + AvatarCacheService.instance.addListener(_onServiceNotify); + } + + @override + void didUpdateWidget(UserAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId || oldWidget.email != widget.email) { + _avatarFile = null; + _loadTriggered = false; + _checkCache(); + } + } + + @override + void dispose() { + AvatarCacheService.instance.removeListener(_onServiceNotify); + super.dispose(); + } + + void _checkCache() { + if (widget.userId.isEmpty) return; + final file = AvatarCacheService.instance.getCachedFile(widget.userId); + if (file != null) { + _avatarFile = file; + } + } + + void _onServiceNotify() { + final file = AvatarCacheService.instance.getCachedFile(widget.userId); + if (file != null && _avatarFile?.path != file.path) { + setState(() => _avatarFile = file); + } + } + + Future _loadIfMissing() async { + if (_loadTriggered || widget.userId.isEmpty) return; + _loadTriggered = true; + + final auth = context.read(); + await AvatarCacheService.instance.getAvatar( + widget.userId, + baseUrl: auth.currentServer?.baseUrl, + token: auth.token?.accessToken, + email: widget.email, + ); + // getAvatar 成功后 notifyListeners → _onServiceNotify 更新 _avatarFile + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // 有缓存文件,显示图片 + if (_avatarFile != null) { + return CircleAvatar( + radius: widget.radius, + backgroundColor: colorScheme.primaryContainer, + backgroundImage: FileImage(_avatarFile!), + ); + } + + // 缓存不存在,触发异步加载 + _loadIfMissing(); + + // Fallback: 首字母(默认展示,避免空白) + return CircleAvatar( + radius: widget.radius, + backgroundColor: colorScheme.primaryContainer, + child: Text( + _initial, + style: TextStyle( + fontSize: widget.radius * 0.75, + fontWeight: FontWeight.bold, + color: colorScheme.onPrimaryContainer, + ), + ), + ); + } +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart new file mode 100644 index 0000000..d6d5d37 --- /dev/null +++ b/lib/router/app_router.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import '../presentation/pages/auth/login_page.dart'; +import '../presentation/pages/shell/app_shell.dart'; +import '../presentation/pages/splash/splash_page.dart'; +import '../presentation/pages/shares/shares_page.dart'; +import '../presentation/pages/recycle_bin/recycle_bin_page.dart'; +import '../presentation/pages/webdav/webdav_page.dart'; +import '../presentation/pages/remote_download/remote_download_page.dart'; +import '../presentation/pages/settings/settings_page.dart'; +import '../presentation/pages/preview/image_preview_page.dart'; +import '../presentation/pages/preview/pdf_preview_page.dart'; +import '../presentation/pages/preview/video_preview_page.dart'; +import '../presentation/pages/preview/audio_preview_page.dart'; +import '../presentation/pages/preview/document_preview_page.dart'; +import '../presentation/pages/preview/markdown_preview_page.dart'; +import '../data/models/file_model.dart'; + +/// 路由名称 +class RouteNames { + static const String splash = '/'; + static const String login = '/login'; + static const String home = '/home'; + static const String settings = '/settings'; + static const String profile = '/profile'; + static const String share = '/share'; + static const String fileDetail = '/file-detail'; + static const String recycleBin = '/recycle-bin'; + static const String webdav = '/webdav'; + static const String remoteDownload = '/remote-download'; + static const String imagePreview = '/image-preview'; + static const String pdfPreview = '/pdf-preview'; + static const String videoPreview = '/video-preview'; + static const String audioPreview = '/audio-preview'; + static const String documentPreview = '/document-preview'; + static const String markdownPreview = '/markdown-preview'; +} + +/// 应用路由 +class AppRouter { + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case RouteNames.splash: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SplashPage(), + ); + + case RouteNames.login: + return MaterialPageRoute( + settings: settings, + builder: (context) => const LoginPage(), + ); + + case RouteNames.home: + return MaterialPageRoute( + settings: settings, + builder: (context) => const AppShell(), + ); + + case RouteNames.share: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SharesPage(), + ); + + case RouteNames.recycleBin: + return MaterialPageRoute( + settings: settings, + builder: (context) => const RecycleBinPage(), + ); + + case RouteNames.webdav: + return MaterialPageRoute( + settings: settings, + builder: (context) => const WebdavPage(), + ); + + case RouteNames.remoteDownload: + return MaterialPageRoute( + settings: settings, + builder: (context) => const RemoteDownloadPage(), + ); + + case RouteNames.settings: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SettingsPage(), + ); + + case RouteNames.imagePreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => ImagePreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => ImagePreviewPage(file: file), + ); + + case RouteNames.pdfPreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => PdfPreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => PdfPreviewPage(file: file), + ); + + case RouteNames.videoPreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => VideoPreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => VideoPreviewPage(file: file), + ); + + case RouteNames.audioPreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => AudioPreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => AudioPreviewPage(file: file), + ); + + case RouteNames.documentPreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => DocumentPreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => DocumentPreviewPage(file: file), + ); + + case RouteNames.markdownPreview: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + settings: settings, + builder: (context) => MarkdownPreviewPage( + file: args['file'] as FileModel, + entityId: args['entityId'] as String?, + ), + ); + } + final file = args as FileModel; + return MaterialPageRoute( + settings: settings, + builder: (context) => MarkdownPreviewPage(file: file), + ); + + default: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SplashPage(), + ); + } + } +} diff --git a/lib/services/admin_service.dart b/lib/services/admin_service.dart new file mode 100644 index 0000000..d90a296 --- /dev/null +++ b/lib/services/admin_service.dart @@ -0,0 +1,124 @@ +import '../data/models/admin_model.dart'; +import 'api_service.dart'; + +/// 管理员服务 +class AdminService { + AdminService._internal(); + static final AdminService _instance = AdminService._internal(); + static AdminService get instance => _instance; + + /// 获取用户组列表 + Future getGroups({ + int page = 1, + int pageSize = 10, + }) async { + final response = await ApiService.instance.post>( + '/admin/group', + data: { + 'page': page, + 'page_size': pageSize, + 'order_by': '', + 'order_direction': 'desc', + }, + ); + return AdminGroupListResponse.fromJson(response); + } + + /// 获取用户列表 + Future getUsers({ + int page = 1, + int pageSize = 10, + Map? conditions, + }) async { + final data = { + 'page': page, + 'page_size': pageSize, + 'order_by': '', + 'order_direction': 'desc', + }; + if (conditions != null) data['conditions'] = conditions; + + final response = await ApiService.instance.post>( + '/admin/user', + data: data, + ); + return AdminUserListResponse.fromJson(response); + } + + /// 创建用户组 + Future createGroup(String name) async { + final response = await ApiService.instance.put>( + '/admin/group', + data: { + 'group': { + 'name': name, + 'permissions': 'hA==', + 'max_storage': 1073741824, + 'settings': { + 'compress_size': 1073741824, + 'decompress_size': 1073741824, + 'max_walked_files': 100000, + 'trash_retention': 604800, + 'source_batch': 10, + 'aria2_batch': 1, + 'redirected_source': true, + }, + 'edges': { + 'storage_policies': {'id': 1}, + }, + 'id': 0, + }, + }, + ); + return AdminGroupModel.fromJson(response); + } + + /// 获取用户组详情(含用户数量) + Future getGroupDetail(int groupId) async { + final response = await ApiService.instance.get>( + '/admin/group/$groupId', + queryParameters: {'countUser': 'true'}, + ); + return AdminGroupModel.fromJson(response); + } + + /// 删除用户组 + Future deleteGroup(int groupId) async { + await ApiService.instance.delete( + '/admin/group/$groupId', + ); + } + + /// 创建用户 + Future createUser({ + required String email, + required String nick, + required String password, + required int groupId, + }) async { + final response = await ApiService.instance.put>( + '/admin/user', + data: { + 'user': { + 'edges': {}, + 'id': 0, + 'email': email, + 'nick': nick, + 'password': password, + 'status': 'active', + 'group_users': groupId, + }, + 'password': password, + }, + ); + return AdminUserModel.fromJson(response); + } + + /// 批量删除用户 + Future batchDeleteUsers(List ids) async { + await ApiService.instance.post( + '/admin/user/batch/delete', + data: {'ids': ids}, + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart new file mode 100644 index 0000000..28836eb --- /dev/null +++ b/lib/services/api_service.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import '../config/api_config.dart'; +import '../core/exceptions/app_exception.dart'; +import '../core/utils/app_logger.dart'; + +/// API响应 +class ApiResponse { + final int code; + final String message; + final T? data; + final String? error; + final String? correlationId; + + ApiResponse({ + required this.code, + required this.message, + this.data, + this.error, + this.correlationId, + }); + + factory ApiResponse.fromJson(Map json) { + return ApiResponse( + code: json['code'] as int? ?? 0, + message: json['msg'] as String? ?? '', + data: json['data'] as T?, + error: json['error'] as String?, + correlationId: json['correlation_id'] as String?, + ); + } + + bool get isSuccess => code == 0; + + bool get isContinue => code == 203; +} + +/// API服务 +class ApiService { + late Dio _dio; + static ApiService? _instance; + bool _isRefreshing = false; + final List> _refreshSubscribers = []; + bool _initialized = false; + + /// 获取 token 的回调 + Future Function()? getTokenCallback; + + /// 刷新 token 的回调 + Future Function()? refreshTokenCallback; + + /// 清除认证数据的回调 + Future Function()? clearAuthCallback; + + ApiService._() { + _dio = Dio( + BaseOptions( + baseUrl: ApiConfig.defaultBaseUrl, + connectTimeout: const Duration(seconds: ApiConfig.connectTimeout), + receiveTimeout: const Duration(seconds: ApiConfig.receiveTimeout), + sendTimeout: const Duration(seconds: ApiConfig.sendTimeout), + headers: {'Content-Type': 'application/json'}, + ), + ); + + _dio.interceptors.add(_requestInterceptor()); + _dio.interceptors.add(_responseInterceptor()); + _dio.interceptors.add(_errorInterceptor()); + } + + /// 是否正在刷新token + bool get isRefreshing => _isRefreshing; + + /// 暴露 Dio 实例(用于二进制下载等不走 _parseResponse 的场景) + Dio get dio => _dio; + + /// 获取单例 + static ApiService get instance { + _instance ??= ApiService._(); + return _instance!; + } + + /// 设置认证回调 + /// 由 AuthProvider 在初始化时调用 + static void setAuthCallbacks({ + required Future Function() getToken, + required Future Function() refreshToken, + required Future Function() clearAuth, + }) { + final service = instance; + service.getTokenCallback = getToken; + service.refreshTokenCallback = refreshToken; + service.clearAuthCallback = clearAuth; + } + + /// 初始化API服务(设置正确的baseUrl) + Future init() async { + if (_initialized) return; + + final baseUrl = await ApiConfig.baseUrl; + _dio.options.baseUrl = baseUrl; + _initialized = true; + } + + /// 动态设置 API baseUrl + Future setBaseUrl(String baseUrl) async { + _dio.options.baseUrl = baseUrl; + AppLogger.d('ApiService baseUrl 已更新为: $baseUrl'); + } + + /// 请求拦截器 + Interceptor _requestInterceptor() { + return InterceptorsWrapper( + onRequest: (options, handler) async { + // 从回调获取 Token + if (getTokenCallback != null) { + final token = await getTokenCallback!(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + } + return handler.next(options); + }, + ); + } + + /// 响应拦截器 + Interceptor _responseInterceptor() { + return InterceptorsWrapper( + onResponse: (response, handler) { + AppLogger.d( + 'API Response: ${response.statusCode} - ${response.requestOptions.uri}', + ); + + // 检查 JSON 响应中的 code 字段 + if (response.data is Map) { + final data = response.data as Map; + final code = data['code'] as int?; + AppLogger.d('_responseInterceptor -> JSON code: $code'); + if (code == 401) { + // HTTP 200 但 JSON code 是 401,需要处理未授权 + final isNoAuth = + response.requestOptions.extra['noAuth'] as bool? ?? false; + AppLogger.d('_responseInterceptor -> isNoAuth: $isNoAuth'); + if (!isNoAuth) { + // 直接在响应拦截器中处理 401 + AppLogger.d('_responseInterceptor -> 触发 401 处理'); + // 异步处理,不阻塞响应 + _handle401InResponse(response.requestOptions); + } + } + } + return handler.next(response); + }, + ); + } + + /// 在响应拦截器中处理 401 错误 + Future _handle401InResponse(RequestOptions requestOptions) async { + final path = requestOptions.path; + if (path.contains('/session/token/refresh')) { + return; + } + + if (_isRefreshing) { + return; + } + + _isRefreshing = true; + try { + AppLogger.d('_handle401InResponse -> 开始刷新 token'); + if (refreshTokenCallback != null) { + await refreshTokenCallback!(); + } + AppLogger.d('_handle401InResponse -> token 刷新完成'); + } catch (e) { + AppLogger.d('_handle401InResponse -> 刷新失败: $e'); + if (clearAuthCallback != null) { + await clearAuthCallback!(); + } + } finally { + _isRefreshing = false; + } + } + + /// 错误拦截器 + Interceptor _errorInterceptor() { + return InterceptorsWrapper( + onError: (error, handler) async { + AppLogger.d("_errorInterceptor -> 获取files列表: response"); + AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}'); + + // 检查是否是 401 错误(HTTP 401 或 JSON code: 401) + bool is401Error = error.response?.statusCode == 401; + if (!is401Error && error.response?.data is Map) { + final data = error.response!.data as Map; + is401Error = data['code'] == 401; + } + + if (is401Error) { + final isNoAuth = + error.requestOptions.extra['noAuth'] as bool? ?? false; + if (!isNoAuth) { + // 不是noAuth请求,需要刷新token + final response = await _handle401Error(error, handler); + if (response != null) { + return handler.resolve(response); + } + } + } + + // 处理错误 + if (error.response == null) { + // 网络错误 + throw NetworkException( + '网络连接失败,请检查网络设置', + code: error.response?.statusCode, + ); + } + + final statusCode = error.response?.statusCode; + final responseData = error.response?.data; + + AppLogger.d('Error Response Data: $responseData'); + + if (responseData is Map) { + final response = ApiResponse.fromJson(responseData); + throw ServerException(response.message, code: response.code); + } + + throw ServerException( + responseData?.toString() ?? '请求失败', + code: statusCode, + ); + }, + ); + } + + /// 处理401错误,尝试刷新token + Future _handle401Error( + DioException error, + ErrorInterceptorHandler handler, + ) async { + // 检查是否需要跳过token检查(如刷新token请求本身) + final path = error.requestOptions.path; + if (path.contains('/session/token/refresh')) { + // 刷新token的请求也失败了,直接返回错误 + return null; + } + + // 如果正在刷新token,等待刷新完成后再重试 + if (_isRefreshing) { + final completer = Completer(); + _refreshSubscribers.add(completer); + + // 等待刷新完成 + await completer.future; + + // 刷新完成后,移除旧的 Authorization header,让拦截器重新添加新 token + error.requestOptions.headers.remove('Authorization'); + + // 重试请求(拦截器会自动添加新 token) + return await _dio.fetch(error.requestOptions); + } + + // 开始刷新token + _isRefreshing = true; + + try { + // 调用回调刷新 token + if (refreshTokenCallback != null) { + await refreshTokenCallback!(); + } + + _isRefreshing = false; + + // 通知所有等待的请求可以重试了 + for (final subscriber in _refreshSubscribers) { + if (!subscriber.isCompleted) { + subscriber.complete(); + } + } + _refreshSubscribers.clear(); + + // 重试当前请求:移除旧 header,让拦截器重新添加新 token + error.requestOptions.headers.remove('Authorization'); + return await _dio.fetch(error.requestOptions); + } catch (e) { + AppLogger.d('Refresh token failed: $e'); + _isRefreshing = false; + + // 刷新失败,清除认证数据 + if (clearAuthCallback != null) { + await clearAuthCallback!(); + } + + // 通知所有等待的请求 + for (final subscriber in _refreshSubscribers) { + if (!subscriber.isCompleted) { + subscriber.completeError(e); + } + } + _refreshSubscribers.clear(); + + // 返回null,让原始错误继续传播 + return null; + } + } + + /// GET请求 , 如果是分享请求, 则不进入 _parseResponse + Future get( + String path, { + Map? queryParameters, + bool noAuth = false, + bool isNoData = false, + Map? headers, + }) async { + final response = await _dio.get( + path, + queryParameters: queryParameters, + options: Options(extra: {'noAuth': noAuth}, headers: headers), + ); + // 如果是分享请求, 则不进入 _parseResponse + if (isNoData) { + return response.data as T; + } + return _parseResponse(response); + } + + /// POST请求 + Future post( + String path, { + dynamic data, + Map? queryParameters, + bool noAuth = false, + Map? headers, + bool isNoData = false, + }) async { + AppLogger.d('API POST Request: $path'); + AppLogger.d('Request Data: $data'); + + final response = await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: Options(extra: {'noAuth': noAuth}, headers: headers), + ); + + AppLogger.d('Response Data: ${response.data}'); + + var isActivEmail = 0; + if (response.statusCode == 200) { + Map? tmp = response.data as Map?; + isActivEmail = tmp?['code'] as int; + } + + if (isNoData || isActivEmail == 203) { + return response.data as T; + } + + return _parseResponse(response); + } + + /// POST请求(带上传进度) + Future postWithProgress( + String path, { + dynamic data, + Map? queryParameters, + bool noAuth = false, + Map? headers, + ProgressCallback? onSendProgress, + }) async { + AppLogger.d('API POST Request with progress: $path'); + + final response = await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: Options(extra: {'noAuth': noAuth}, headers: headers), + onSendProgress: onSendProgress, + ); + + AppLogger.d('Response Data: ${response.data}'); + + return _parseResponse(response); + } + + /// PUT请求 + Future put( + String path, { + dynamic data, + bool noAuth = false, + bool isNoData = false, + }) async { + final response = await _dio.put( + path, + data: data, + options: Options(extra: {'noAuth': noAuth}), + ); + // 当请求的接口为创建分享时, 逻辑上不适合走到 _parseResponse -> ApiResponse.fromJson 直接返回结果即可 + if (isNoData) { + return response.data as T; + } + return _parseResponse(response); + } + + /// PATCH请求 + Future patch( + String path, { + dynamic data, + Map? queryParameters, + bool noAuth = false, + }) async { + final response = await _dio.patch( + path, + data: data, + queryParameters: queryParameters, + options: Options(extra: {'noAuth': noAuth}), + ); + return _parseResponse(response); + } + + /// DELETE请求 + Future delete( + String path, { + dynamic data, + Map? queryParameters, + bool noAuth = false, + }) async { + final response = await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + options: Options(extra: {'noAuth': noAuth}), + ); + return _parseResponse(response); + } + + /// 解析响应 + T _parseResponse(Response response) { + final data = response.data; + if (data is Map) { + final apiResponse = ApiResponse.fromJson(data); + if (!apiResponse.isSuccess && !apiResponse.isContinue) { + throw ServerException(apiResponse.message, code: apiResponse.code); + } + return apiResponse.data as T; + } + return data as T; + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..a8feaf0 --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,231 @@ +import '../data/models/user_model.dart'; +import 'api_service.dart'; +import '../core/utils/app_logger.dart'; +import '../core/exceptions/app_exception.dart'; + +/// 认证服务 +class AuthService { + // 私有化构造函数,防止在外部被直接实例化 + AuthService._internal(); + + // 存储单例的静态私有变量 + static final AuthService _instance = AuthService._internal(); + + // 公开一个 getter 叫 instance (或者使用工厂构造函数) + static AuthService get instance => _instance; + + /// 准备登录 + Future> prepareLogin(String email) async { + final response = await ApiService.instance.get>( + '/session/prepare', + queryParameters: {'email': email}, + noAuth: true, + ); + return response as Map; + } + + /// 密码登录 + Future passwordLogin({ + required String email, + required String password, + String? captcha, + }) async { + final data = { + 'email': email, + 'password': password, + ...captcha != null ? {'captcha': captcha} : {}, + }; + + final response = await ApiService.instance.post>( + '/session/token', + data: data, + noAuth: true, + ); + + AppLogger.d('AuthService -> 登录响应: $response'); + + // code 203 表示需要两步验证 + final code = response['code'] as int?; + if (code == 203) { + final sessionId = response['data'] as String; + throw TwoFactorRequiredException(sessionId); + } + + return LoginResponseModel.fromJson(response); + } + + /// 2FA登录 + Future twoFactorLogin({ + required String otp, + required String sessionId, + }) async { + final data = {'otp': otp, 'session_id': sessionId}; + + final response = await ApiService.instance.post>( + '/session/token/2fa', + data: data, + noAuth: true, + ); + + return LoginResponseModel.fromJson(response); + } + + /// 刷新Token + /// 这个方法现在由 ApiService 调用,传入当前的 refreshToken + Future refreshToken(String refreshToken) async { + final data = {'refresh_token': refreshToken}; + + final response = await ApiService.instance.post>( + '/session/token/refresh', + data: data, + noAuth: true, + ); + + return TokenModel.fromJson(response); + } + + /// 登出 + /// 现在由 ServerService 和 AuthProvider 负责清除本地数据 + Future logout() async { + try { + // 登出需要调用 API,但 refreshToken 由调用方提供 + // 这个方法现在主要用于调用登出 API + await ApiService.instance.delete( + '/session/token', + data: {}, + noAuth: true, + ); + } catch (e) { + // 登出失败也要清除本地数据(由调用方处理) + rethrow; + } + } + + /// 获取当前用户信息 + Future getCurrentUser() async { + final response = await ApiService.instance.get>( + '/user/me', + ); + return UserModel.fromJson(response); + } + + /// 获取用户容量 + Future getUserCapacity() async { + final response = await ApiService.instance.get>( + '/user/capacity', + ); + return CapacityModel.fromJson(response); + } + + /// 发送重置密码邮件 + Future sendResetPasswordEmail({ + required String email, + String? captcha, + String? ticket, + }) async { + final data = { + 'email': email, + ...captcha != null ? {'captcha': captcha} : {}, + ...ticket != null ? {'ticket': ticket} : {}, + }; + + final response = await ApiService.instance.post>( + '/user/reset', + data: data, + noAuth: true, + isNoData: true, + ); + + final code = response['code'] as int?; + if (code != 0) { + final msg = response['msg'] as String? ?? '发送失败'; + throw Exception(msg); + } + } + + /// 用户注册 + Future signUp({ + required String email, + required String password, + String? language, + String? captcha, + String? ticket, + }) async { + final data = { + 'email': email, + 'password': password, + ...language != null ? {'language': language} : {}, + ...captcha != null ? {'captcha': captcha} : {}, + ...ticket != null ? {'ticket': ticket} : {}, + }; + + final response = await ApiService.instance.post>( + '/user', + data: data, + noAuth: true, + ); + + final code = response['code'] as int?; + final msg = response['msg'] as String?; + + if (code != 0 && code != 203) { + throw Exception('注册失败: $msg'); + } + + return SignUpResponse( + code: code ?? 0, + msg: msg, + requiresEmailActivation: code == 203, + ); + } +} + +/// 注册响应 +class SignUpResponse { + final int code; + final String? msg; + final bool requiresEmailActivation; + + SignUpResponse({ + required this.code, + this.msg, + required this.requiresEmailActivation, + }); +} + +/// 登录响应模型 +/// 这个模型现在将 token 合并到 user 中返回 +class LoginResponseModel { + final UserModel user; + + LoginResponseModel({required this.user}); + + factory LoginResponseModel.fromJson(Map json) { + // 检查是否为错误响应(包含 code 和 msg 但没有 data 或 user) + final code = json['code'] as int?; + final msg = json['msg'] as String?; + final Map? data = json['data'] as Map?; + + // 如果 code 不是 0,说明是错误响应 + if (code != null && code != 0) { + throw Exception(msg ?? '登录失败'); + } + + // 如果没有 data 且也没有 user,说明是错误响应 + if (data == null && json['user'] == null) { + throw Exception(msg ?? '登录失败'); + } + + final Map userData = data ?? json; + final userJson = userData['user'] as Map; + + // 将 token 合并到 user 中 + userJson['token'] = userData['token']; + + return LoginResponseModel(user: UserModel.fromJson(userJson)); + } + + Map toJson() { + return {'user': user.toJson()}; + } +} diff --git a/lib/services/avatar_cache_service.dart b/lib/services/avatar_cache_service.dart new file mode 100644 index 0000000..605b3b6 --- /dev/null +++ b/lib/services/avatar_cache_service.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; +import 'package:cloudreve4_flutter/core/utils/avatar_utils.dart'; + +/// 头像缓存服务 +/// 通过 Dio 下载头像,保存为本地文件 {userId}_avatar.webp +/// 服务器头像 200 优先,否则使用 Gravatar (identicon 兜底) +class AvatarCacheService extends ChangeNotifier { + static AvatarCacheService? _instance; + static AvatarCacheService get instance { + _instance ??= AvatarCacheService._(); + return _instance!; + } + AvatarCacheService._(); + + Directory? _cacheDir; + final Map _pathCache = {}; + final Map _sizeCache = {}; + final Set _loadingSet = {}; + + /// 初始化缓存目录 + Future init() async { + final appDir = await getApplicationSupportDirectory(); + _cacheDir = Directory('${appDir.path}/avatar_cache'); + if (!_cacheDir!.existsSync()) { + _cacheDir!.createSync(recursive: true); + } + _scanExistingFiles(); + } + + void _scanExistingFiles() { + if (_cacheDir == null) return; + for (final entity in _cacheDir!.listSync()) { + if (entity is File && entity.path.endsWith('_avatar.webp')) { + final name = entity.uri.pathSegments.last; + final userId = name.replaceAll('_avatar.webp', ''); + _pathCache[userId] = entity.path; + try { + _sizeCache[userId] = entity.lengthSync(); + } catch (_) {} + } + } + } + + String _filePath(String userId) => '${_cacheDir!.path}/${userId}_avatar.webp'; + + /// 头像缓存文件是否存在(同步) + bool avatarIsExist(String userId) { + if (userId.isEmpty) return false; + final cached = _pathCache[userId]; + if (cached != null && File(cached).existsSync()) return true; + return File(_filePath(userId)).existsSync(); + } + + /// 检查服务器头像是否有更新(HEAD 请求比对 Content-Length) + /// 返回 true 表示已更新并重新缓存,false 表示无需更新或检查失败 + Future avatarIsUpdated(String userId, String baseUrl, String token) async { + if (userId.isEmpty) return false; + if (!avatarIsExist(userId)) { + // 本地无缓存,直接走 getAvatar + return await getAvatar(userId, baseUrl: baseUrl, token: token); + } + + try { + final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId); + // 使用独立 Dio 实例,避免 ApiService 拦截器干扰 + final dio = Dio(); + final response = await dio.head( + url, + options: Options( + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (s) => s != null && s < 500, + followRedirects: false, + ), + ); + + // 非 200(包括 302 指向 Gravatar)视为无自定义头像,无需比对更新 + if (response.statusCode != 200) return false; + + final remoteSize = response.headers.value('content-length'); + if (remoteSize == null) return false; + final remoteBytes = int.tryParse(remoteSize) ?? -1; + + final localBytes = _sizeCache[userId] ?? + (File(_filePath(userId)).existsSync() + ? File(_filePath(userId)).lengthSync() + : -1); + + AppLogger.d('头像更新检查 ($userId): 本地=$localBytes, 远程=$remoteBytes'); + + if (remoteBytes != localBytes) { + // 大小不一致,重新获取 + return await getAvatar(userId, baseUrl: baseUrl, token: token); + } + return false; + } catch (e) { + AppLogger.d('头像更新检查失败 ($userId): $e'); + return false; + } + } + + /// 获取头像:并发请求服务器头像 + Gravatar,服务器 200 优先,否则用 Gravatar + /// 返回 true 表示成功获取并缓存,false 表示全部失败 + Future getAvatar(String userId, + {String? baseUrl, String? token, String? email}) async { + if (userId.isEmpty || _cacheDir == null) return false; + + // 防止同一 userId 并发 + if (_loadingSet.contains(userId)) return false; + _loadingSet.add(userId); + + try { + final futures = >[]; + + // 1. 服务器头像 + if (baseUrl != null && baseUrl.isNotEmpty && token != null && token.isNotEmpty) { + futures.add(_fetchServerAvatar(userId, baseUrl, token)); + } + + // 2. Gravatar (identicon 兜底) + if (email != null && email.isNotEmpty) { + futures.add(_fetchGravatar(userId, email)); + } + + if (futures.isEmpty) return false; + + // 并发请求 + final results = await Future.wait(futures); + final serverResult = results.whereType<_ServerAvatarResult>().firstOrNull; + final gravatarResult = results.whereType<_GravatarResult>().firstOrNull; + + // 服务器头像 200 优先 + if (serverResult != null && serverResult.statusCode == 200 && serverResult.bytes != null) { + _saveFile(userId, serverResult.bytes!); + AppLogger.d('头像获取成功 (服务器, $userId), 大小=${serverResult.bytes!.length}'); + notifyListeners(); + return true; + } + + // 服务器 404/302 或失败,用 Gravatar + if (gravatarResult != null && gravatarResult.bytes != null) { + _saveFile(userId, gravatarResult.bytes!); + AppLogger.d('头像获取成功 (Gravatar, $userId), 大小=${gravatarResult.bytes!.length}'); + notifyListeners(); + return true; + } + + AppLogger.d('头像获取失败 ($userId): 服务器状态=${serverResult?.statusCode}, Gravatar=${gravatarResult != null ? '失败' : '未请求'}'); + return false; + } finally { + _loadingSet.remove(userId); + } + } + + Future<_AvatarResult> _fetchServerAvatar(String userId, String baseUrl, String token) async { + try { + final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId); + // 使用独立 Dio 实例,避免 ApiService 拦截器干扰 + final dio = Dio(); + final response = await dio.get>( + url, + options: Options( + responseType: ResponseType.bytes, + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (s) => s != null && s < 500, + // 不跟随重定向:302 指向官方 Gravatar,跟随会绕过镜像加速 + followRedirects: false, + ), + ); + return _ServerAvatarResult(response.statusCode ?? 0, response.data); + } catch (e) { + AppLogger.d('请求服务器头像异常 ($userId): $e'); + return _ServerAvatarResult(0, null); + } + } + + Future<_AvatarResult> _fetchGravatar(String userId, String email) async { + try { + final url = await AvatarUtils.getGravatarUrl(email, size: 400); + final dio = Dio(); + final response = await dio.get>( + url, + options: Options( + responseType: ResponseType.bytes, + validateStatus: (s) => s != null && s < 500, + ), + ); + if (response.statusCode == 200 && response.data != null && response.data!.isNotEmpty) { + return _GravatarResult(response.data!); + } + return _GravatarResult(null); + } catch (e) { + AppLogger.d('请求Gravatar头像异常 ($userId): $e'); + return _GravatarResult(null); + } + } + + void _saveFile(String userId, List bytes) { + if (_cacheDir == null) return; + final path = _filePath(userId); + final file = File(path); + file.writeAsBytesSync(bytes); + _pathCache[userId] = path; + _sizeCache[userId] = bytes.length; + } + + /// 获取已缓存的头像文件(同步,快速) + File? getCachedFile(String userId) { + if (_cacheDir == null || userId.isEmpty) return null; + final cached = _pathCache[userId]; + if (cached != null && File(cached).existsSync()) { + return File(cached); + } + final path = _filePath(userId); + if (File(path).existsSync()) { + _pathCache[userId] = path; + return File(path); + } + _pathCache.remove(userId); + _sizeCache.remove(userId); + return null; + } + + /// 批量检查头像是否需要更新(限制并发,避免密集 IO/网络阻塞主线程) + Future batchCheckUpdates( + List userIds, { + required String baseUrl, + required String token, + int concurrency = 3, + }) async { + var index = 0; + final completer = Completer(); + var activeCount = 0; + var completedCount = 0; + final total = userIds.length; + + void scheduleNext() { + while (activeCount < concurrency && index < total) { + final userId = userIds[index++]; + activeCount++; + avatarIsUpdated(userId, baseUrl, token).whenComplete(() { + activeCount--; + completedCount++; + if (completedCount >= total && !completer.isCompleted) { + completer.complete(); + } else { + scheduleNext(); + } + }); + } + if (completedCount >= total && !completer.isCompleted) { + completer.complete(); + } + } + + scheduleNext(); + await completer.future; + } + + /// 清除指定用户的头像缓存(头像上传/切换后调用) + Future evictCache(String userId) async { + _pathCache.remove(userId); + _sizeCache.remove(userId); + final path = _filePath(userId); + final file = File(path); + if (file.existsSync()) { + await file.delete(); + } + notifyListeners(); + } + + /// 清除所有头像缓存(登出时调用) + Future clearAllCache() async { + _pathCache.clear(); + _sizeCache.clear(); + if (_cacheDir != null && _cacheDir!.existsSync()) { + await _cacheDir!.delete(recursive: true); + _cacheDir!.createSync(recursive: true); + } + notifyListeners(); + } +} + +/// 请求结果基类 +abstract class _AvatarResult {} + +class _ServerAvatarResult extends _AvatarResult { + final int statusCode; + final List? bytes; + _ServerAvatarResult(this.statusCode, this.bytes); +} + +class _GravatarResult extends _AvatarResult { + final List? bytes; + _GravatarResult(this.bytes); +} diff --git a/lib/services/cache_manager_service.dart b/lib/services/cache_manager_service.dart new file mode 100644 index 0000000..d0eab85 --- /dev/null +++ b/lib/services/cache_manager_service.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import '../data/models/cache_settings_model.dart'; +import '../core/constants/storage_keys.dart'; +import 'storage_service.dart'; + +/// 缓存管理器服务 +class CacheManagerService { + static CacheManagerService? _instance; + BaseCacheManager? _manager; + CacheSettingsModel _settings = CacheSettingsModel(); + + CacheManagerService._(); + + /// 获取单例 + static CacheManagerService get instance { + _instance ??= CacheManagerService._(); + return _instance!; + } + + /// 初始化 + Future initialize() async { + try { + await loadSettings(); + await _initializeManager(); + } catch (e) { + // 忽略初始化错误,使用默认值 + AppLogger.e('CacheManagerService initialize error: $e'); + } + } + + /// 初始化管理器 + Future _initializeManager() async { + _manager = CacheManager( + Config( + 'cloudreve_cache', + stalePeriod: Duration(milliseconds: _settings.cacheExpireDuration), + maxNrOfCacheObjects: 1000, + ), + ); + } + + /// 获取缓存管理器 + BaseCacheManager get manager { + if (_manager == null) { + throw Exception('CacheManagerService 未初始化,请先调用 initialize()'); + } + return _manager!; + } + + /// 获取缓存目录 + Future getCacheDir() async { + final tempDir = await getTemporaryDirectory(); + final cacheDir = Directory('${tempDir.path}/cloudreve_cache'); + if (!cacheDir.existsSync()) { + cacheDir.createSync(recursive: true); + } + return cacheDir; + } + + /// 获取当前缓存大小 + Future getCacheSize() async { + final cacheDir = await getCacheDir(); + if (!cacheDir.existsSync()) { + return 0; + } + + try { + int totalSize = 0; + final entities = cacheDir.listSync(recursive: true, followLinks: false); + for (final entity in entities) { + if (entity is File) { + try { + totalSize += entity.lengthSync(); + } catch (e) { + // 忽略无法读取的文件 + } + } + } + return totalSize; + } catch (e) { + return 0; + } + } + + /// 清空缓存 + Future clearCache() async { + final cacheDir = await getCacheDir(); + if (cacheDir.existsSync()) { + try { + await cacheDir.delete(recursive: true); + } on PathAccessException { + // Windows 文件占用,逐个删除可删除的文件 + try { + final entities = cacheDir.listSync(recursive: true, followLinks: false); + for (final entity in entities) { + if (entity is File) { + try { await entity.delete(); } catch (_) {} + } + } + try { await cacheDir.delete(recursive: true); } catch (_) {} + } catch (_) {} + } + } + try { + await _manager?.emptyCache(); + } on PathAccessException { + // nothing to do + } + await _initializeManager(); + } + + /// 清理过期文件 + Future cleanExpiredFiles() async { + try { + await manager.emptyCache(); + } catch (e) { + // 忽略清理错误 + } + } + + /// 加载设置 + Future loadSettings() async { + final settingsJson = await StorageService.instance.getString( + StorageKeys.cacheSettings, + ); + if (settingsJson != null && settingsJson.isNotEmpty) { + try { + final settings = CacheSettingsModel.fromJson( + jsonDecode(settingsJson) as Map, + ); + _settings = settings; + } catch (e) { + // 使用默认设置 + } + } + } + + /// 保存设置 + Future saveSettings(CacheSettingsModel settings) async { + _settings = settings; + await StorageService.instance.setString( + StorageKeys.cacheSettings, + jsonEncode(settings.toJson()), + ); + await _initializeManager(); + } + + /// 获取当前设置 + CacheSettingsModel get settings => _settings; + + /// 获取缓存文件信息 + Future> getCacheFiles() async { + final cacheDir = await getCacheDir(); + final files = []; + + if (!cacheDir.existsSync()) { + return files; + } + + await for (final entity in cacheDir.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { + final stat = await entity.stat(); + files.add( + CacheFileInfo( + path: entity.path, + name: entity.uri.pathSegments.last, + size: stat.size, + lastModified: stat.modified, + ), + ); + } catch (e) { + // 忽略无法读取的文件 + } + } + } + + return files; + } + + /// 删除指定的缓存文件 + Future deleteCacheFile(String path) async { + final file = File(path); + if (file.existsSync()) { + try { + await file.delete(); + } on PathAccessException { + // Windows 文件占用,忽略 + } + } + } + + /// 自动清理最旧文件(当超过最大缓存大小时) + Future autoCleanOldFiles() async { + if (!_settings.autoCleanOldFiles) { + return; + } + + final currentSize = await getCacheSize(); + if (currentSize <= _settings.maxCacheSize) { + return; + } + + final files = await getCacheFiles(); + // 按最后修改时间排序,最旧的在前 + files.sort((a, b) => a.lastModified.compareTo(b.lastModified)); + + int deletedSize = 0; + for (final file in files) { + if (currentSize - deletedSize <= _settings.maxCacheSize) { + break; + } + await deleteCacheFile(file.path); + deletedSize += file.size; + } + } +} + +/// 缓存文件信息 +class CacheFileInfo { + final String path; + final String name; + final int size; + final DateTime lastModified; + + CacheFileInfo({ + required this.path, + required this.name, + required this.size, + required this.lastModified, + }); + + String get sizeReadable { + if (size < 1024) { + return '$size B'; + } else if (size < 1024 * 1024) { + return '${(size / 1024).toStringAsFixed(1)} KB'; + } else if (size < 1024 * 1024 * 1024) { + return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + } +} diff --git a/lib/services/desktop_service.dart b/lib/services/desktop_service.dart new file mode 100644 index 0000000..cb601b1 --- /dev/null +++ b/lib/services/desktop_service.dart @@ -0,0 +1,211 @@ +import 'dart:io'; +import 'package:flutter_acrylic/window.dart'; +import 'package:flutter_acrylic/window_effect.dart'; +import 'package:path/path.dart' as p; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:tray_manager/tray_manager.dart'; +import '../config/app_config.dart'; +import '../core/utils/app_logger.dart'; +import '../presentation/providers/theme_provider.dart'; + +/// 桌面端服务(窗口管理 + 系统托盘) +class DesktopService with TrayListener, WindowListener { + static DesktopService? _instance; + DesktopService._(); + + static DesktopService get instance { + _instance ??= DesktopService._(); + return _instance!; + } + + static bool get isDesktopPlatform => + Platform.isWindows || Platform.isLinux; + + bool _initialized = false; + + /// 初始化桌面端服务,必须在 runApp 之前调用 + Future initialize() async { + if (!isDesktopPlatform || _initialized) return; + + // 1. 初始化窗口管理器 + await windowManager.ensureInitialized(); + windowManager.addListener(this); + + // --- 新增:初始化 flutter_acrylic --- + if (Platform.isWindows) { + var themeProvider = ThemeProvider(); + await Window.initialize(); + // 设置 Mica 效果 + await Window.setEffect( + effect: WindowEffect.mica, + // 根据你的 ThemeProvider 判断是深色还是浅色 Mica + dark: themeProvider.isDark, // 建议这里先写死 false 测试,后续对接 ThemeProvider + ); + } + + // 2. 窗口选项设置 + WindowOptions windowOptions = const WindowOptions( + size: Size(1280, 720), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.hidden, + ); + + // 3. 等待窗口准备就绪后执行居中和显示 + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.setTitle(AppConfig.appName); + await windowManager.setMinimumSize(const Size(400, 300)); + await windowManager.setPreventClose(true); + + // 如果设置了 center: true 仍未生效(某些 Linux 环境),可以手动补刀: + // await windowManager.center(); + + await windowManager.show(); + await windowManager.focus(); + }); + + // 托盘管理器 + trayManager.addListener(this); + + final iconPath = await _getTrayIconPath(); + AppLogger.d('DesktopService: tray icon path: $iconPath'); + await trayManager.setIcon(iconPath); + + try { + await trayManager.setToolTip(AppConfig.appName); + } catch (e) { + AppLogger.e('DesktopService: tray icon error: $e'); + } + + final menu = Menu(items: [ + MenuItem(key: 'show', label: '显示主窗口'), + MenuItem.separator(), + MenuItem(key: 'quit', label: '退出'), + ]); + await trayManager.setContextMenu(menu); + + _initialized = true; + AppLogger.d('DesktopService initialized'); + } + + Future _getTrayIconPath() async { + if (Platform.isWindows) { + // 获取当前可执行文件 (.exe) 所在的目录 + String exePath = Platform.resolvedExecutable; + String exeDir = p.dirname(exePath); + + // 拼接 Windows 下 Flutter Assets 的标准物理路径 + // 注意:这里的路径必须与打包后的文件夹结构一致 + return p.join(exeDir, 'data', 'flutter_assets', 'assets/icons/tray_icon.ico'); + } else if (Platform.isLinux) { + return '/opt/cloudreve4/data/flutter_assets/assets/icons/tray_icon.png'; + } + // 调试模式下通常直接用 assets 路径 + return 'assets/icons/tray_icon.png'; + } + + // ========== TrayListener ========== + + @override + void onTrayIconMouseDown() { + showWindow(); + } + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onTrayIconRightMouseUp() {} + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'show': + showWindow(); + break; + case 'quit': + _quitApp(); + break; + } + } + + // ========== WindowListener ========== + + @override + void onWindowClose() async { + AppLogger.d('DesktopService: onWindowClose -> hiding to tray'); + await windowManager.hide(); + } + + @override + void onWindowFocus() {} + + @override + void onWindowBlur() {} + + @override + void onWindowMaximize() {} + + @override + void onWindowUnmaximize() {} + + @override + void onWindowMinimize() {} + + @override + void onWindowRestore() {} + + @override + void onWindowResize() {} + + @override + void onWindowResized() {} + + @override + void onWindowMove() {} + + @override + void onWindowMoved() {} + + @override + void onWindowEnterFullScreen() {} + + @override + void onWindowLeaveFullScreen() {} + + // ========== Private ========== + + Future showWindow() async { + await windowManager.show(); + await windowManager.focus(); + } + + Future _quitApp() async { + try { + AppLogger.d('DesktopService: Cleaning up before exit...'); + + // 彻底解绑 + windowManager.removeListener(this); + trayManager.removeListener(this); + + // 彻底销毁托盘(防止残留僵尸图标) + await trayManager.destroy(); + + // 允许关闭并销毁窗口 + await windowManager.setPreventClose(false); + + // 给系统一点点时间(50ms)处理最后的事件队列 + await Future.delayed(const Duration(milliseconds: 50)); + + await windowManager.destroy(); + } catch (e) { + AppLogger.e('Exit error: $e'); + } finally { + exit(0); + } + } +} diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart new file mode 100644 index 0000000..1a99a0c --- /dev/null +++ b/lib/services/download_service.dart @@ -0,0 +1,461 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:background_downloader/background_downloader.dart' as bd; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../core/constants/storage_keys.dart'; +import '../data/models/download_task_model.dart'; +import 'file_service.dart'; +import 'storage_service.dart'; +import '../core/utils/app_logger.dart'; + +/// 下载服务 - 单例模式 +/// 所有平台统一使用 background_downloader +class DownloadService { + static final DownloadService _instance = DownloadService._internal(); + factory DownloadService() => _instance; + + DownloadService._internal(); + + // 统一映射:外部下载器 task ID → 内部 task ID + final Map _externalTaskIdToInternalId = {}; + final Map _internalIdToExternalTaskId = {}; + + // 存储 background_downloader 的 DownloadTask 对象,用于暂停/恢复/取消 + final Map _bdTasks = {}; + + // 回调处理器 + static Function(String taskId, DownloadStatus status, int progress)? + _callbackHandler; + + final FileService _fileService = FileService(); + final Map> _progressControllers = + {}; + bool _isInitialized = false; + + /// 设置回调处理器 + static void setCallbackHandler( + Function(String taskId, DownloadStatus status, int progress) handler) { + _callbackHandler = handler; + } + + /// 获取下载任务进度流 + Stream getProgressStream(String taskId) { + if (!_progressControllers.containsKey(taskId)) { + _progressControllers[taskId] = + StreamController.broadcast(); + } + return _progressControllers[taskId]!.stream; + } + + /// 获取下载目录 + Future getDownloadDirectory() async { + if (defaultTargetPlatform == TargetPlatform.android) { + if (await Permission.notification.isDenied) { + await Permission.notification.request(); + } + final status = await Permission.manageExternalStorage.request(); + + if (status.isPermanentlyDenied) { + throw Exception('存储权限被永久拒绝,请在设置中开启'); + } + + if (!status.isGranted) { + throw Exception('存储权限被拒绝'); + } + + final directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + final appDocDir = await getApplicationDocumentsDirectory(); + final directory = Directory('${appDocDir.path}/Downloads'); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } else { + // Windows/Linux/macOS - 使用系统下载目录 + final downloadsDir = await getDownloadsDirectory(); + if (downloadsDir != null) { + return downloadsDir; + } + // 回退方案 + if (Platform.isWindows) { + final userProfile = Platform.environment['USERPROFILE'] ?? ''; + final dir = Directory('$userProfile\\Downloads'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + final home = Platform.environment['HOME'] ?? ''; + final dir = Directory('$home/Downloads'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + } + + /// 读取 WiFi-only 下载设置 + Future isWifiOnlyEnabled() async { + return await StorageService.instance + .getBool(StorageKeys.downloadWifiOnly) ?? + false; + } + + /// 读取重试次数设置 + Future getRetries() async { + return await StorageService.instance + .getInt(StorageKeys.downloadRetries) ?? + 3; + } + + /// 初始化下载器 + Future initialize( + {Function(String taskId, DownloadStatus status, int progress)? + callbackHandler}) async { + if (callbackHandler != null) { + setCallbackHandler(callbackHandler); + AppLogger.d('回调处理器已更新'); + } + + if (_isInitialized) { + AppLogger.d('DownloadService 已经初始化'); + return; + } + + // 配置通知(Android 前台服务需要通知栏显示) + if (Platform.isAndroid) { + bd.FileDownloader().configureNotification( + running: const bd.TaskNotification( + '正在下载', '文件: {filename} - {progress}'), + complete: + const bd.TaskNotification('下载完成', '文件: {filename} 已保存'), + error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'), + paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'), + progressBar: true, + tapOpensFile: true, + ); + AppLogger.d('background_downloader 通知已配置'); + } + + bd.FileDownloader().registerCallbacks( + taskStatusCallback: _handleBdStatusUpdate, + taskProgressCallback: _handleBdProgressUpdate, + ); + + // 启动任务追踪和恢复 + await bd.FileDownloader().start( + doTrackTasks: true, + markDownloadedComplete: true, + doRescheduleKilledTasks: true, + ); + + _isInitialized = true; + AppLogger.d('DownloadService 初始化完成 (background_downloader)'); + } + + /// background_downloader 状态回调 + void _handleBdStatusUpdate(bd.TaskStatusUpdate update) { + final internalId = _externalTaskIdToInternalId[update.task.taskId]; + // 如果映射不存在,尝试从 metaData 恢复 + if (internalId == null && update.task.metaData.isNotEmpty) { + final metaInternalId = update.task.metaData; + _externalTaskIdToInternalId[update.task.taskId] = metaInternalId; + _internalIdToExternalTaskId[metaInternalId] = update.task.taskId; + _bdTasks[metaInternalId] = update.task as bd.DownloadTask; + AppLogger.d( + '从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId'); + } + + final resolvedInternalId = + _externalTaskIdToInternalId[update.task.taskId]; + if (resolvedInternalId == null) { + AppLogger.d( + 'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}'); + return; + } + + DownloadStatus status; + switch (update.status) { + case bd.TaskStatus.enqueued: + status = DownloadStatus.waiting; + case bd.TaskStatus.running: + status = DownloadStatus.downloading; + case bd.TaskStatus.complete: + status = DownloadStatus.completed; + case bd.TaskStatus.notFound: + case bd.TaskStatus.failed: + status = DownloadStatus.failed; + case bd.TaskStatus.canceled: + status = DownloadStatus.cancelled; + case bd.TaskStatus.paused: + status = DownloadStatus.paused; + case bd.TaskStatus.waitingToRetry: + status = DownloadStatus.waiting; + } + + AppLogger.d( + 'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status'); + + final progress = status == DownloadStatus.completed ? 100 : 0; + _callbackHandler?.call(resolvedInternalId, status, progress); + } + + /// background_downloader 进度回调 + void _handleBdProgressUpdate(bd.TaskProgressUpdate update) { + final internalId = _externalTaskIdToInternalId[update.task.taskId]; + if (internalId == null) return; + + final progress = (update.progress * 100).toInt().clamp(0, 100); + _callbackHandler?.call(internalId, DownloadStatus.downloading, progress); + } + + /// 开始下载(新任务) + Future startDownload(DownloadTaskModel task) async { + try { + if (!_isInitialized) { + await initialize(); + } + + // 获取下载 URL + String url = task.downloadUrl ?? ''; + if (url.isEmpty) { + final response = await _fileService.getDownloadUrls( + uris: [task.fileUri], + download: true, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isEmpty) { + throw Exception('无法获取下载链接'); + } + + final urlData = urls[0] as Map; + url = urlData['url'] as String; + } + + // 确保保存目录存在 + final file = File(task.savePath); + final dir = file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + // 新任务:如果完整文件已存在,删除它 + if (await file.exists() && task.downloadedBytes == 0) { + await file.delete(); + } + + return _startBdDownload(task, url, dir); + } catch (e) { + AppLogger.d('下载失败: $e'); + rethrow; + } + } + + /// 使用 background_downloader 开始下载 + Future _startBdDownload( + DownloadTaskModel task, String url, Directory dir) async { + final wifiOnly = await isWifiOnlyEnabled(); + final retries = await getRetries(); + final bdTask = bd.DownloadTask( + url: url, + filename: task.fileName, + directory: dir.path, + baseDirectory: bd.BaseDirectory.root, + updates: bd.Updates.statusAndProgress, + allowPause: true, + retries: retries, + requiresWiFi: wifiOnly, + metaData: task.id, + ); + + final success = await bd.FileDownloader().enqueue(bdTask); + + if (!success) { + throw Exception('创建下载任务失败'); + } + + // 保存映射关系 + _externalTaskIdToInternalId[bdTask.taskId] = task.id; + _internalIdToExternalTaskId[task.id] = bdTask.taskId; + _bdTasks[task.id] = bdTask; + + // 保存 backgroundTaskId 到任务模型(持久化,用于重启后恢复映射) + task.backgroundTaskId = bdTask.taskId; + + AppLogger.d( + 'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries'); + + return bdTask.taskId; + } + + /// 恢复下载(用于重启后恢复暂停的任务) + Future resumeDownloadAfterRestart( + DownloadTaskModel task) async { + try { + if (!_isInitialized) { + await initialize(); + } + + // 获取下载 URL + String url = task.downloadUrl ?? ''; + if (url.isEmpty) { + final response = await _fileService.getDownloadUrls( + uris: [task.fileUri], + download: true, + ); + + final urls = response['urls'] as List? ?? []; + if (urls.isEmpty) { + throw Exception('无法获取下载链接'); + } + + final urlData = urls[0] as Map; + url = urlData['url'] as String; + } + + final file = File(task.savePath); + final dir = file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + // 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask + final wifiOnly = await isWifiOnlyEnabled(); + final retries = await getRetries(); + final bdTask = bd.DownloadTask( + url: url, + filename: task.fileName, + directory: dir.path, + baseDirectory: bd.BaseDirectory.root, + updates: bd.Updates.statusAndProgress, + allowPause: true, + retries: retries, + requiresWiFi: wifiOnly, + metaData: task.id, + ); + + // 恢复映射关系 + _externalTaskIdToInternalId[bdTask.taskId] = task.id; + _internalIdToExternalTaskId[task.id] = bdTask.taskId; + _bdTasks[task.id] = bdTask; + task.backgroundTaskId = bdTask.taskId; + + // 如果有已下载的部分,尝试 resume;否则 enqueue + final partialFile = + File('${dir.path}/${task.fileName}.part'); + if (task.downloadedBytes > 0 && await partialFile.exists()) { + AppLogger.d( + '断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes'); + await bd.FileDownloader().resume(bdTask); + } else { + AppLogger.d('重新下载: ${task.fileName}'); + final success = await bd.FileDownloader().enqueue(bdTask); + if (!success) { + throw Exception('创建下载任务失败'); + } + } + + return bdTask.taskId; + } catch (e) { + AppLogger.d('恢复下载失败: $e'); + rethrow; + } + } + + /// 暂停下载 + Future pauseDownload(String taskId) async { + final bdTask = _bdTasks[taskId]; + if (bdTask != null) { + await bd.FileDownloader().pause(bdTask); + } + } + + /// 恢复下载 + Future resumeDownload(String taskId) async { + if (!_isInitialized) { + await initialize(); + } + final bdTask = _bdTasks[taskId]; + if (bdTask != null) { + await bd.FileDownloader().resume(bdTask); + } + } + + /// 取消下载 + Future cancelDownload(String taskId) async { + final bdTask = _bdTasks[taskId]; + if (bdTask != null) { + await bd.FileDownloader().cancel(bdTask); + } + } + + /// 删除下载任务 + void disposeTask(String taskId) { + final externalId = _internalIdToExternalTaskId[taskId]; + if (externalId != null) { + _externalTaskIdToInternalId.remove(externalId); + } + _internalIdToExternalTaskId.remove(taskId); + _bdTasks.remove(taskId); + + // 关闭进度流 + final controller = _progressControllers[taskId]; + if (controller != null) { + controller.close(); + _progressControllers.remove(taskId); + } + } + + /// 删除已下载的文件 + Future deleteDownloadedFile(String savePath) async { + try { + final file = File(savePath); + if (await file.exists()) { + await file.delete(); + } + // 同时删除部分文件 + final partialFile = File('$savePath.part'); + if (await partialFile.exists()) { + await partialFile.delete(); + } + } catch (e) { + AppLogger.d('删除文件失败: $e'); + } + } + + /// 获取可读的文件大小 + static String getReadableFileSize(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'; + } + } + + /// 清理所有资源 + void dispose() { + _externalTaskIdToInternalId.clear(); + _internalIdToExternalTaskId.clear(); + _bdTasks.clear(); + + // 关闭所有流 + for (final controller in _progressControllers.values) { + if (!controller.isClosed) { + controller.close(); + } + } + _progressControllers.clear(); + } +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart new file mode 100644 index 0000000..fbe6e5a --- /dev/null +++ b/lib/services/file_service.dart @@ -0,0 +1,250 @@ +import 'api_service.dart'; +import '../core/utils/app_logger.dart'; +import '../core/utils/file_utils.dart'; + +/// 文件服务 +class FileService { + + /// 列出文件 + Future> listFiles({ + required String uri, + int page = 0, + int? pageSize, + String? orderBy, + String? orderDirection, + String? nextPageToken, + }) async { + final params = { + 'uri': FileUtils.toCloudreveUri(uri), + 'page': page, + 'page_size': pageSize, + 'order_by': orderBy, + 'order_direction': orderDirection, + 'next_page_token': nextPageToken, + }; + + final response = await ApiService.instance + .get>('/file', queryParameters: params); + + return response; + } + + /// 创建文件/文件夹 + Future> createFile({ + required String uri, + required String type, + bool? errOnConflict, + Map? metadata, + }) async { + final data = { + 'uri': FileUtils.toCloudreveUri(uri), + 'type': type, + 'err_on_conflict': ?errOnConflict, + 'metadata': ?metadata, + }; + + final response = await ApiService.instance + .post>('/file/create', data: data); + + return response; + } + + /// 删除文件 + Future deleteFiles({ + required List uris, + bool unlink = false, + bool skipSoftDelete = false, + }) async { + final data = { + 'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(), + if (unlink) 'unlink': true, + if (skipSoftDelete) 'skip_soft_delete': true, + }; + + await ApiService.instance.delete('/file', data: data); + } + + /// 移动/复制文件 + Future moveFiles({ + required List uris, + required String dst, + bool copy = false, + }) async { + final data = { + 'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(), + 'dst': FileUtils.toCloudreveUri(dst), + 'copy': copy, + }; + + await ApiService.instance.post('/file/move', data: data); + } + + /// 重命名文件(返回更新后的文件对象) + Future> renameFile({ + required String uri, + required String newName, + }) async { + final data = { + 'uri': FileUtils.toCloudreveUri(uri), + 'new_name': newName, + }; + + final response = await ApiService.instance.post>( + '/file/rename', + data: data, + ); + return response; + } + + /// 获取下载链接 + Future> getDownloadUrls({ + required List uris, + bool download = true, + bool? redirect, + String? entity, + bool? usePrimarySiteUrl, + bool? skipError, + bool? archive, + bool? noCache, + String? contextHint, + }) async { + final data = { + 'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(), + 'download': download, + 'redirect': ?redirect, + 'entity': ?entity, + 'use_primary_site_url': ?usePrimarySiteUrl, + 'skip_error': ?skipError, + 'archive': ?archive, + 'no_cache': ?noCache, + }; + + final headers = {}; + if (contextHint != null) { + headers['X-Cr-Context-Hint'] = contextHint; + } + + final response = await ApiService.instance.post>( + '/file/url', + data: data, + headers: headers, + ); + + return response; + } + + /// 创建直接链接(分享链接) + Future>> createDirectLinks({ + required List uris, + }) async { + final data = { + 'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(), + }; + + final response = await ApiService.instance.put>>( + '/file/source', + data: data, + ); + + return response; + } + + /// 恢复文件(从回收站) + Future restoreFiles({ + required List uris, + }) async { + final data = { + 'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(), + }; + + await ApiService.instance.post('/file/restore', data: data); + } + + /// 列出回收站文件 + Future> listTrashFiles({ + int page = 0, + int? pageSize, + }) async { + final params = { + 'uri': 'cloudreve://trash', + 'page': page, + 'page_size': pageSize, + }; + + final response = await ApiService.instance + .get>('/file', queryParameters: params); + + return response; + } + + /// 搜索文件 + Future> searchFiles({ + String uri = '/', + required String name, + bool caseFolding = false, + int page = 0, + int? pageSize, + }) async { + // 构造搜索 URI: cloudreve://my?name=xxx + final cloudreveUri = '${FileUtils.toCloudreveUri(uri)}?name=$name'; + + final params = { + 'uri': cloudreveUri, + 'page': page, + 'case_folding': caseFolding, + 'page_size': pageSize, + }; + + final response = await ApiService.instance + .get>('/file', queryParameters: params); + + AppLogger.d('Search files ---------> : $response'); + return response; + } + + /// 获取文件/文件夹详细信息 + Future> getFileInfo({ + String? uri, + String? id, + bool extended = false, + bool folderSummary = false, + }) async { + final params = { + if (uri != null) 'uri': FileUtils.toCloudreveUri(uri), + 'id': ?id, + 'extended': extended, + 'folder_summary': folderSummary, + }; + + final response = await ApiService.instance + .get>('/file/info', queryParameters: params); + AppLogger.d("getFileInfo --> $response"); + return response; + } + + /// 设为当前版本 + Future setFileVersion({ + required String uri, + required String version, + }) async { + final data = { + 'uri': FileUtils.toCloudreveUri(uri), + 'version': version, + }; + + await ApiService.instance.post('/file/version/current', data: data); + } + + /// 删除版本 + Future deleteFileVersion({ + required String uri, + required String version, + }) async { + final data = { + 'uri': FileUtils.toCloudreveUri(uri), + 'version': version, + }; + + await ApiService.instance.delete('/file/version', data: data); + } +} diff --git a/lib/services/remote_download_service.dart b/lib/services/remote_download_service.dart new file mode 100644 index 0000000..5c8ce29 --- /dev/null +++ b/lib/services/remote_download_service.dart @@ -0,0 +1,72 @@ +import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; + +import 'api_service.dart'; +import '../data/models/remote_download_task_model.dart'; + +/// 离线下载服务 +class RemoteDownloadService { + /// 创建离线下载任务 + Future> createDownload({ + required String dst, + List? src, + String? srcFile, + }) async { + final data = { + 'dst': dst, + if (src != null && src.isNotEmpty) 'src': src, + if (srcFile != null && srcFile.isNotEmpty) 'src_file': srcFile, + }; + + final response = await ApiService.instance + .post('/workflow/download', data: data); + + AppLogger.i("RemoteDownloadService --> $response"); + + List result = response.map((item) => + RemoteDownloadTaskModel.fromJson(item as Map) + ).toList(); + + return result; + } + + /// 列出任务 + /// [category] 任务分类:downloading(下载中)、downloaded(已完成)、general(全部) + Future> listTasks({ + required String category, + int pageSize = 20, + String? nextPageToken, + }) async { + final params = { + 'page_size': pageSize, + 'category': category, + 'next_page_token': ?nextPageToken, + }; + + return await ApiService.instance + .get>('/workflow', queryParameters: params); + } + + /// 获取任务进度 + Future> getProgress({required String taskId}) async { + return await ApiService.instance + .get>('/workflow/progress/$taskId'); + } + + /// 选择种子文件 + Future selectFiles({ + required String taskId, + required List> files, + }) async { + await ApiService.instance.patch>( + '/workflow/download/$taskId', + data: {'files': files}, + ); + } + + /// 取消任务 + Future cancelTask({required String taskId}) async { + await ApiService.instance.delete>( + '/workflow/download/$taskId', + ); + } +} diff --git a/lib/services/server_service.dart b/lib/services/server_service.dart new file mode 100644 index 0000000..b8360bd --- /dev/null +++ b/lib/services/server_service.dart @@ -0,0 +1,170 @@ +import '../config/api_config.dart'; +import '../data/models/server_model.dart'; +import '../data/models/user_model.dart'; +import 'storage_service.dart'; +import '../core/utils/app_logger.dart'; + +/// 服务器服务 - 固定使用内置服务器 +class ServerService { + static ServerService? _instance; + ServerService._(); + + static ServerService get instance { + _instance ??= ServerService._(); + return _instance!; + } + + static const String _defaultLabel = '共云网盘'; + static const String _defaultBaseUrl = ApiConfig.defaultBaseUrl; + + List _servers = []; + ServerModel? _currentServer; + + /// 获取服务器列表 + List get servers => List.unmodifiable(_servers); + + /// 获取当前服务器 + ServerModel? get currentServer => _currentServer; + + /// 初始化服务器 + Future init() async { + await _loadServers(); + } + + /// 从存储加载服务器列表 + Future _loadServers() async { + try { + // 从 StorageService 加载服务器列表 + final loadedServers = await StorageService.instance.servers; + _setFixedDefaultServer(loadedServers); + await _saveServers(); + await _saveLastSelected(); + + AppLogger.d('加载了 ${_servers.length} 个服务器配置'); + AppLogger.d('当前服务器: ${_currentServer?.label}'); + } catch (e) { + AppLogger.d('加载服务器列表失败: $e'); + // 加载失败时使用默认服务器 + _servers = [ + ServerModel( + label: _defaultLabel, + baseUrl: _defaultBaseUrl, + ), + ]; + _currentServer = _servers.first; + } + } + + /// 固定使用内置服务器,并保留同地址服务器上的登录信息 + void _setFixedDefaultServer(List loadedServers) { + ServerModel? savedDefaultServer; + for (final server in loadedServers) { + if (server.baseUrl == _defaultBaseUrl) { + savedDefaultServer = server; + break; + } + } + + _currentServer = (savedDefaultServer ?? + ServerModel( + label: _defaultLabel, + baseUrl: _defaultBaseUrl, + )) + .copyWith( + label: _defaultLabel, + baseUrl: _defaultBaseUrl, + ); + + _servers = [_currentServer!]; + } + + /// 保存服务器列表到存储 + Future _saveServers() async { + try { + await StorageService.instance.setServers(_servers); + AppLogger.d('已保存 ${_servers.length} 个服务器配置'); + } catch (e) { + AppLogger.d('保存服务器列表失败: $e'); + } + } + + /// 保存上次选中的服务器 + Future _saveLastSelected() async { + if (_currentServer != null) { + await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label); + } + } + + /// 固定服务器模式下不支持添加服务器 + Future addServer(ServerModel server) async { + throw UnsupportedError('当前版本不支持自定义服务器'); + } + + /// 固定服务器模式下不支持更新服务器 + Future updateServer(String oldLabel, ServerModel newServer) async { + throw UnsupportedError('当前版本不支持自定义服务器'); + } + + /// 固定服务器模式下不支持删除服务器 + Future deleteServer(String label) async { + throw UnsupportedError('当前版本不支持自定义服务器'); + } + + /// 固定服务器模式下始终使用内置服务器 + Future selectServer(String label) async { + if (_currentServer == null || _currentServer!.baseUrl != _defaultBaseUrl) { + _setFixedDefaultServer(_servers); + } + await _saveLastSelected(); + AppLogger.d('当前服务器: ${_currentServer?.label}'); + } + + /// 更新当前服务器的服务登录信息 + Future updateCurrentServerLogin({ + String? email, + String? password, + UserModel? user, + bool? rememberMe, + }) async { + if (_currentServer == null) { + throw Exception('没有选中的服务器'); + } + + _currentServer = _currentServer!.copyWith( + email: email, + password: password, + user: user, + rememberMe: rememberMe ?? _currentServer!.rememberMe, + ); + + // 更新列表中的引用 + final index = _servers.indexWhere((s) => s.label == _currentServer!.label); + if (index != -1) { + _servers[index] = _currentServer!; + } + + await _saveServers(); + } + + /// 清除当前服务器的服务登录信息 + Future clearCurrentServerLogin() async { + await updateCurrentServerLogin( + email: null, + password: null, + user: null, + ); + } + + /// 重置为默认服务器列表 + Future resetToDefault() async { + _servers = [ + ServerModel( + label: _defaultLabel, + baseUrl: _defaultBaseUrl, + ), + ]; + _currentServer = _servers.first; + await _saveServers(); + await _saveLastSelected(); + } +} diff --git a/lib/services/share_service.dart b/lib/services/share_service.dart new file mode 100644 index 0000000..a0a41f7 --- /dev/null +++ b/lib/services/share_service.dart @@ -0,0 +1,126 @@ +import 'api_service.dart'; +import '../data/models/share_model.dart'; +import '../core/utils/app_logger.dart'; + +/// 分享服务 +class ShareService { + /// 将文件系统路径转换为 cloudreve URI 格式 + 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'; + } + + /// 创建分享链接 + Future createShare({ + required String uri, + bool? isPrivate, + bool? shareView, + int? expire, + int? price, + String? password, + bool? showReadme, + }) async { + final data = { + 'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='}, + 'uri': _toCloudreveUri(uri), + 'is_private': ?isPrivate, + 'share_view': ?shareView, + 'expire': ?expire, + 'price': ?price, + 'password': ?password, + 'show_readme': ?showReadme, + }; + // 当请求的接口为创建分享时, 逻辑上不适合走到 _parseResponse -> ApiResponse.fromJson 直接返回结果即可 + final response = await ApiService.instance.put>( + '/share', + data: data, + isNoData: true, + ); + return response['data'] as String; + } + + /// 获取我的分享列表 + Future> listShares({ + required int pageSize, + String? orderBy, + String? orderDirection, + String? nextPageToken, + }) async { + final queryParams = { + 'page_size': pageSize, + 'order_by': ?orderBy, + 'order_direction': ?orderDirection, + 'next_page_token': ?nextPageToken, + }; + // 请求方法为get, claude 写成post, fixed + return await ApiService.instance.get>( + '/share', + queryParameters: queryParams, + ); + } + + /// 获取分享详情 + Future getShareInfo({ + required String id, + String? password, + bool? countViews, + bool? ownerExtended, + }) async { + final queryParams = {}; + if (password != null) queryParams['password'] = password; + if (countViews != null) queryParams['count_views'] = countViews.toString(); + if (ownerExtended != null) { + queryParams['owner_extended'] = ownerExtended.toString(); + } + // 获取分享详情是 GET 请求 + final response = await ApiService.instance.get>( + '/share/info/$id', + queryParameters: queryParams, + ); + // 获取分享详情返回的 response 已经经过 _parseResponse -> ApiResponse.fromJson 处理, 不需要再通过 ['data'] 获取数据 + // return ShareModel.fromJson(response['data'] as Map); + return ShareModel.fromJson(response); + } + + /// 编辑分享 + Future editShare({ + required String id, + required String uri, + bool? isPrivate, + String? password, + bool? shareView, + int? downloads, + int? expire, + }) async { + final data = { + 'uri': uri, + 'is_private': ?isPrivate, + 'share_view': ?shareView, + 'downloads': ?downloads, + 'expire': ?expire, + }; + if (password != null && password.isNotEmpty) { + data['password'] = password; + } + + AppLogger.d('editShare response ---> : response'); + final response = await ApiService.instance.post>( + '/share/$id', + data: data, + isNoData: true, + ); + return response['data'] as String; + } + + /// 删除分享 + Future deleteShare({required String id}) async { + await ApiService.instance.delete('/share/$id'); + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 0000000..e9c2e74 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../core/constants/storage_keys.dart'; +import '../data/models/server_model.dart'; + +/// 存储服务 +class StorageService { + static StorageService? _instance; + SharedPreferences? _prefs; + + StorageService._(); + + /// 获取单例 + static StorageService get instance { + _instance ??= StorageService._(); + return _instance!; + } + + /// 初始化 + Future init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + /// 获取值 + Future getString(String key) async { + await init(); + return _prefs!.getString(key); + } + + /// 设置值 + Future setString(String key, String? value) async { + await init(); + if (value == null) { + return _prefs!.remove(key); + } + return _prefs!.setString(key, value); + } + + /// 获取整数值 + Future getInt(String key) async { + await init(); + return _prefs!.getInt(key); + } + + /// 设置整数值 + Future setInt(String key, int? value) async { + await init(); + if (value == null) { + return _prefs!.remove(key); + } + return _prefs!.setInt(key, value); + } + + /// 获取布尔值 + Future getBool(String key) async { + await init(); + return _prefs!.getBool(key); + } + + /// 设置布尔值 + Future setBool(String key, bool? value) async { + await init(); + if (value == null) { + return _prefs!.remove(key); + } + return _prefs!.setBool(key, value); + } + + /// 删除值 + Future remove(String key) async { + await init(); + return _prefs!.remove(key); + } + + /// 清空所有数据 + Future clear() async { + await init(); + return _prefs!.clear(); + } + + /// 设置 + Future get themeMode => getString(StorageKeys.themeMode); + Future setThemeMode(String value) => setString(StorageKeys.themeMode, value); + + /// 服务器地址配置 + Future get customBaseUrl => getString(StorageKeys.customBaseUrl); + Future setCustomBaseUrl(String? value) => setString(StorageKeys.customBaseUrl, value); + Future removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl); + + /// 服务器列表 + Future> get servers async { + final serversJson = await getString(StorageKeys.servers); + if (serversJson == null || serversJson.isEmpty) { + return []; + } + + try { + final serversList = jsonDecode(serversJson) as List; + return serversList + .map((e) => ServerModel.fromJson(e as Map)) + .toList(); + } catch (e) { + return []; + } + } + + Future setServers(List servers) async { + try { + final serversJson = jsonEncode(servers.map((s) => s.toJson()).toList()); + return await setString(StorageKeys.servers, serversJson); + } catch (e) { + return false; + } + } + + /// 上次选中的服务器 label + Future get lastSelectedServerLabel => getString(StorageKeys.lastSelectedServer); + Future setLastSelectedServerLabel(String? value) => setString(StorageKeys.lastSelectedServer, value); + + /// 搜索历史(最新在前,最多 20 条) + Future> getSearchHistory() async { + final json = await getString(StorageKeys.searchHistory); + if (json == null || json.isEmpty) return []; + try { + final list = jsonDecode(json) as List; + return list.cast(); + } catch (_) { + return []; + } + } + + Future setSearchHistory(List history) async { + try { + final json = jsonEncode(history); + return await setString(StorageKeys.searchHistory, json); + } catch (_) { + return false; + } + } + + Future addToSearchHistory(String query) async { + final history = await getSearchHistory(); + history.remove(query); + history.insert(0, query); + if (history.length > 20) history.removeRange(20, history.length); + await setSearchHistory(history); + } + + Future clearSearchHistory() async { + await remove(StorageKeys.searchHistory); + } +} diff --git a/lib/services/thumbnail_service.dart b/lib/services/thumbnail_service.dart new file mode 100644 index 0000000..7340edb --- /dev/null +++ b/lib/services/thumbnail_service.dart @@ -0,0 +1,136 @@ +import 'api_service.dart'; +import '../core/utils/app_logger.dart'; +import '../core/utils/file_utils.dart'; +import '../core/utils/time_flow_decoder.dart'; + +/// 缩略图缓存条目 +class _ThumbCacheEntry { + final String imageUrl; + final DateTime expiresAt; + + _ThumbCacheEntry({required this.imageUrl, required this.expiresAt}); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +/// 缩略图服务 — 获取、解码、缓存缩略图 URL +class ThumbnailService { + static ThumbnailService? _instance; + static ThumbnailService get instance { + _instance ??= ThumbnailService._(); + return _instance!; + } + + ThumbnailService._(); + + // 内存缓存: fileUri -> cache entry + final Map _urlCache = {}; + + // 请求去重: fileUri -> in-flight Future + final Map> _inFlightRequests = {}; + + /// 获取缩略图图片 URL + /// [fileUri] 文件的 relativePath(如 /path/to/file.jpg) + /// [contextHint] 可选,加速服务端 DB 查询 + Future getThumbnailUrl({ + required String fileUri, + String? contextHint, + }) async { + final cacheKey = fileUri; + + // 1. 检查内存缓存 + final cached = _urlCache[cacheKey]; + if (cached != null && !cached.isExpired) { + return cached.imageUrl; + } + if (cached != null) { + _urlCache.remove(cacheKey); + } + + // 2. 检查请求去重 + final inFlight = _inFlightRequests[cacheKey]; + if (inFlight != null) { + return inFlight; + } + + // 3. 发起请求 + final future = _fetchThumbnailUrl(fileUri, contextHint); + _inFlightRequests[cacheKey] = future; + + try { + return await future; + } finally { + _inFlightRequests.remove(cacheKey); + } + } + + Future _fetchThumbnailUrl(String fileUri, String? contextHint) async { + try { + final uri = FileUtils.toCloudreveUri(fileUri); + final headers = contextHint != null + ? {'X-Cr-Context-Hint': contextHint} + : null; + + // _parseResponse 已提取 ApiResponse.data,response 即 {url, obfuscated, expires} + final response = await ApiService.instance.get>( + '/file/thumb', + queryParameters: {'uri': uri}, + headers: headers, + ); + + AppLogger.d('ThumbnailService: response for $fileUri = $response'); + + var url = response['url'] as String? ?? ''; + final obfuscated = response['obfuscated'] as bool? ?? false; + final expiresStr = response['expires'] as String?; + + // 解码混淆 URL + if (obfuscated && url.isNotEmpty) { + final decoded = TimeFlowDecoder.decodeTimeFlowString(url); + if (decoded == null) { + AppLogger.w('Failed to decode obfuscated thumbnail URL for $fileUri'); + return null; + } + url = decoded; + } + + if (url.isEmpty) return null; + + AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url'); + + // 解析过期时间,缓存提前 30 秒过期 + DateTime expiresAt; + if (expiresStr != null) { + try { + expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30)); + } catch (_) { + expiresAt = DateTime.now().add(const Duration(minutes: 5)); + } + } else { + expiresAt = DateTime.now().add(const Duration(minutes: 5)); + } + + // 存入内存缓存 + _urlCache[fileUri] = _ThumbCacheEntry( + imageUrl: url, + expiresAt: expiresAt, + ); + + return url; + } catch (e) { + AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e'); + return null; + } + } + + /// 移除指定文件的缓存 URL + void evictUrl(String fileUri) { + _urlCache.remove(fileUri); + } + + /// 清空所有缓存(目录切换时调用) + void clearAll() { + _urlCache.clear(); + _inFlightRequests.clear(); + } +} diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart new file mode 100644 index 0000000..3f3a2cf --- /dev/null +++ b/lib/services/upload_service.dart @@ -0,0 +1,586 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../core/constants/storage_keys.dart'; +import '../data/models/upload_task_model.dart'; +import 'storage_service.dart'; +import 'api_service.dart'; +import '../core/utils/app_logger.dart'; + +/// 上传服务 - 单例模式 +class UploadService extends ChangeNotifier { + UploadService._internal() : super(); + + factory UploadService() => instance; + + static UploadService? _instance; + static UploadService get instance { + _instance ??= UploadService._internal(); + return _instance!; + } + + final Map _tasks = {}; + + /// 上传完成回调:参数为 (目标路径, 文件名) + void Function(String targetPath, String fileName)? onUploadCompleted; + final Map _cancelTokens = {}; + final Map> _progressControllers = {}; + final Map _speedTrackers = {}; + + /// 添加任务 + void addTask(UploadTaskModel task) { + _tasks[task.id] = task; + if (!_progressControllers.containsKey(task.id)) { + _progressControllers[task.id] = StreamController.broadcast(); + } + AppLogger.d('UploadTaskModel -> addTask > ${task.toJson()}'); + _saveTasks(); + notifyListeners(); + } + + /// 更新任务 + void updateTask(UploadTaskModel task) { + if (_tasks.containsKey(task.id)) { + _tasks[task.id] = task; + _saveTasks(); + notifyListeners(); + } + } + + /// 获取任务 + UploadTaskModel? getTask(String id) => _tasks[id]; + + /// 获取所有任务 + List get allTasks => _tasks.values.toList(); + + /// 获取进行中的任务 + List get activeTasks => _tasks.values + .where( + (t) => + t.status == UploadStatus.uploading || + t.status == UploadStatus.waiting, + ) + .toList(); + + /// 移除任务 + void removeTask(String id) { + _tasks.remove(id); + _cancelTokens.remove(id); + final controller = _progressControllers.remove(id); + controller?.close(); + _saveTasks(); + notifyListeners(); + } + + /// 获取上传进度流 + Stream getProgressStream(String taskId) { + if (!_progressControllers.containsKey(taskId)) { + _progressControllers[taskId] = StreamController.broadcast(); + } + return _progressControllers[taskId]!.stream; + } + + /// 清除所有任务 + void clearAllTasks() { + for (final controller in _progressControllers.values) { + controller.close(); + } + _tasks.clear(); + _cancelTokens.clear(); + _progressControllers.clear(); + _saveTasks(); + notifyListeners(); + } + + /// 清除已完成的任务 + void clearCompletedTasks() { + final completedIds = _tasks.values + .where( + (t) => + t.status == UploadStatus.completed || + t.status == UploadStatus.cancelled, + ) + .map((t) => t.id) + .toList(); + + for (final id in completedIds) { + removeTask(id); + } + _saveTasks(); + } + + /// 清除失败的任务 + void clearFailedTasks() { + final failedIds = _tasks.values + .where((t) => t.status == UploadStatus.failed) + .map((t) => t.id) + .toList(); + + for (final id in failedIds) { + removeTask(id); + } + _saveTasks(); + } + + /// 初始化上传服务 + Future initialize() async { + await _loadTasks(); + } + + /// 从本地存储加载上传任务 + Future _loadTasks() async { + try { + final tasksJson = await StorageService.instance.getString( + StorageKeys.uploadTasks, + ); + if (tasksJson == null || tasksJson.isEmpty) { + AppLogger.d('没有保存的上传任务'); + return; + } + + final tasksList = jsonDecode(tasksJson) as List; + final loadedTasks = []; + + final now = DateTime.now(); + for (final taskJson in tasksList) { + try { + final task = UploadTaskModel.fromJson( + taskJson as Map, + ); + + // 检查文件是否存在 + if (!await task.file.exists()) { + AppLogger.d('上传任务文件不存在,跳过: ${task.fileName}'); + continue; + } + + // 过滤掉已取消的任务 + if (task.status == UploadStatus.cancelled) { + continue; + } + + // 如果任务已完成,只保留配置天数内的记录 + if (task.status == UploadStatus.completed) { + if (task.completedAt == null) { + continue; + } + final retentionDays = await StorageService.instance + .getInt(StorageKeys.taskRetentionDays) ?? + 7; + if (retentionDays > 0) { + final daysSinceCompletion = now + .difference(task.completedAt!) + .inDays; + if (daysSinceCompletion > retentionDays) { + AppLogger.d('跳过超过$retentionDays天的已完成任务: ${task.fileName}'); + continue; + } + } + } + + // 对于未完成的任务,重置状态为等待(因为应用关闭后上传已停止) + if (task.status == UploadStatus.uploading || + task.status == UploadStatus.waiting) { + loadedTasks.add( + task.copyWith( + status: UploadStatus.waiting, + uploadedBytes: 0, + progress: 0, + uploadedChunks: 0, + errorMessage: null, + ), + ); + } else { + loadedTasks.add(task); + } + } catch (e) { + AppLogger.d('解析上传任务失败: $e'); + } + } + + // 将加载的任务添加到当前任务列表 + for (final task in loadedTasks) { + _tasks[task.id] = task; + if (!_progressControllers.containsKey(task.id)) { + _progressControllers[task.id] = StreamController.broadcast(); + } + } + + AppLogger.d('从存储加载了 ${loadedTasks.length} 个上传任务'); + + // 通知 UI 更新 + if (loadedTasks.isNotEmpty) { + notifyListeners(); + } + } catch (e) { + AppLogger.d('加载上传任务失败: $e'); + } + } + + /// 保存上传任务到本地存储 + Future _saveTasks() async { + try { + final tasksList = _tasks.values.map((task) => task.toJson()).toList(); + final tasksJson = jsonEncode(tasksList); + await StorageService.instance.setString( + StorageKeys.uploadTasks, + tasksJson, + ); + AppLogger.d('已保存 ${_tasks.length} 个上传任务到存储'); + } catch (e) { + AppLogger.d('保存上传任务失败: $e'); + } + } + + /// 取消上传 + void cancelUpload(String taskId) { + final cancelToken = _cancelTokens[taskId]; + if (cancelToken != null) { + cancelToken.cancel('上传已取消'); + } + + final task = _tasks[taskId]; + if (task != null) { + updateTask(task.copyWith(status: UploadStatus.cancelled, speed: 0)); + _cleanSpeedTracker(taskId); + } + } + + /// 重试上传 + Future retryUpload(String taskId) async { + final task = _tasks[taskId]; + if (task == null) return; + + // 重置任务状态 + updateTask( + task.copyWith( + status: UploadStatus.waiting, + uploadedBytes: 0, + progress: 0, + uploadedChunks: 0, + errorMessage: null, + ), + ); + + // 开始上传 + await startUpload(task); + } + + /// 开始上传 + Future startUpload(UploadTaskModel task) async { + AppLogger.d('UploadService.startUpload: 开始上传任务 ${task.fileName}'); + final cancelToken = CancelToken(); + _cancelTokens[task.id] = cancelToken; + + try { + // 步骤1:创建上传会话 + AppLogger.d('UploadService.startUpload: 创建上传会话...'); + final session = await _createUploadSession(task); + AppLogger.d( + 'UploadService.startUpload: 上传会话创建成功,sessionId=${session.sessionId}, chunkSize=${session.chunkSize}', + ); + + // 更新任务,添加会话信息 + final updatedTask = task.copyWith( + session: session, + totalChunks: task.calculateTotalChunks(session.chunkSize), + status: UploadStatus.uploading, + ); + updateTask(updatedTask); + + // 步骤2:上传文件 + await _uploadFile(updatedTask, cancelToken); + + // 上传完成 + final completedTask = updatedTask.copyWith( + status: UploadStatus.completed, + progress: 1.0, + uploadedBytes: task.fileSize, + uploadedChunks: updatedTask.totalChunks, + completedAt: DateTime.now(), + speed: 0, + ); + updateTask(completedTask); + _cleanSpeedTracker(task.id); + + onUploadCompleted?.call(task.targetPath, task.fileName); + + _emitProgress(task.id, 1.0); + } catch (e) { + AppLogger.d('Upload failed for ${task.fileName}: $e'); + + final isCancelled = + e is DioException && e.type == DioExceptionType.cancel; + + updateTask( + task.copyWith( + status: isCancelled ? UploadStatus.cancelled : UploadStatus.failed, + errorMessage: e.toString(), + speed: 0, + ), + ); + _cleanSpeedTracker(task.id); + + if (!isCancelled) { + _emitProgress(task.id, task.progress); + } + } + } + + /// 创建上传会话 + Future _createUploadSession(UploadTaskModel task) async { + final response = await ApiService.instance.put>( + '/file/upload', + data: { + 'uri': task.targetPath.endsWith('/') + ? '${task.targetPath}${task.fileName}' + : '${task.targetPath}/${task.fileName}', + 'size': task.fileSize, + }, + ); + + // API: /file/upload 响应格式: {code: 0, data: {...}, msg: ''} 但在 _parseResponse 已经解析过 data, 这里直接使用 response + final Map sessionData = response; + + return UploadSessionModel.fromJson(sessionData); + } + + /// 上传文件(支持分片上传) + Future _uploadFile( + UploadTaskModel task, + CancelToken cancelToken, + ) async { + final session = task.session!; + final file = task.file; + + AppLogger.d('开始上传 -> ${task.fileName}'); + + // 读取文件 + final fileBytes = await file.readAsBytes(); + + // 检查是否需要分片:服务器返回了分片大小 或 文件大于20MB + const largeFileThreshold = 20 * 1024 * 1024; // 20MB + final shouldMultipart = session.isMultipartEnabled || task.fileSize > largeFileThreshold; + + if (shouldMultipart) { + // 分片上传 + final chunkSize = session.isMultipartEnabled ? session.chunkSize : largeFileThreshold; + await _uploadMultipart(fileBytes, chunkSize, task, cancelToken); + } else { + // 单次上传 + await _uploadSinglePart(fileBytes, session, task, cancelToken); + } + } + + /// 分片上传 + Future _uploadMultipart( + List fileBytes, + int chunkSize, + UploadTaskModel task, + CancelToken cancelToken, + ) async { + final session = task.session!; + final totalChunks = (fileBytes.length / chunkSize).ceil(); + + for (int i = 0; i < totalChunks; i++) { + if (cancelToken.isCancelled) { + throw DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.cancel, + error: '上传已取消', + ); + } + + // 计算当前分片的范围 + final start = i * chunkSize; + final end = (start + chunkSize).clamp(0, fileBytes.length); + final chunkData = fileBytes.sublist(start, end); + + AppLogger.d('Uploading chunk ${i + 1}/$totalChunks for ${task.fileName}'); + + if (session.isRelayUpload) { + // 上传到 Cloudreve 服务器 + await _uploadChunkToRelay(chunkData, i, session.sessionId, cancelToken, null); + } else { + // 上传到远程存储 + await _uploadChunkToRemote(chunkData, i, session, cancelToken, null); + } + + // 更新进度 - 获取最新任务状态并计算正确进度 + final currentTask = getTask(task.id) ?? task; + final progress = (i + 1) / totalChunks; + final speed = _computeSpeed(task.id, end); + updateTask( + currentTask.copyWith( + uploadedBytes: end, + progress: progress, + uploadedChunks: i + 1, + speed: speed, + ), + ); + _emitProgress(task.id, progress); + } + + // 完成上传(某些存储策略需要) + if (session.completeUrl != null && session.completeUrl!.isNotEmpty) { + await _completeMultipartUpload(session); + } + } + + /// 上传分片到中继服务器 + Future _uploadChunkToRelay( + List chunkData, + int index, + String sessionId, + CancelToken cancelToken, + void Function(int, int)? onProgress, + ) async { + await ApiService.instance.postWithProgress( + '/file/upload/$sessionId/$index', + data: Stream.fromIterable([chunkData]), + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': chunkData.length.toString(), + }, + onSendProgress: onProgress, + ); + } + + /// 上传分片到远程存储 + Future _uploadChunkToRemote( + List chunkData, + int index, + UploadSessionModel session, + CancelToken cancelToken, + void Function(int, int)? onProgress, + ) async { + final urls = session.uploadUrls ?? []; + if (urls.isEmpty) { + throw Exception('没有可用的上传 URL'); + } + + // 大多数远程存储使用一个 URL,通过 query 参数指定分片索引 + final url = urls.first; + final uploadUrl = url.contains('?') + ? '$url&chunk=$index' + : '$url?chunk=$index'; + + final dio = Dio(BaseOptions()); + + await dio.post( + uploadUrl, + data: Stream.fromIterable([chunkData]), + options: Options( + contentType: 'application/octet-stream', + headers: { + 'Content-Length': chunkData.length.toString(), + if (session.credential != null) 'Authorization': session.credential, + }, + ), + cancelToken: cancelToken, + onSendProgress: onProgress, + ); + } + + /// 完成多部分上传 + Future _completeMultipartUpload(UploadSessionModel session) async { + await ApiService.instance.post(session.completeUrl!, data: {}); + } + + /// 单次上传 + Future _uploadSinglePart( + List fileBytes, + UploadSessionModel session, + UploadTaskModel task, + CancelToken cancelToken, + ) async { + if (session.isRelayUpload) { + // 上传到中继服务器 + await _uploadChunkToRelay( + fileBytes, + 0, + session.sessionId, + cancelToken, + (sent, total) { + final progress = sent / total; + final currentTask = getTask(task.id) ?? task; + final speed = _computeSpeed(task.id, sent); + updateTask( + currentTask.copyWith( + uploadedBytes: sent, + progress: progress, + speed: speed, + ), + ); + _emitProgress(task.id, progress); + }, + ); + } else { + // 上传到远程存储 + await _uploadChunkToRemote( + fileBytes, + 0, + session, + cancelToken, + (sent, total) { + final progress = sent / total; + final currentTask = getTask(task.id) ?? task; + final speed = _computeSpeed(task.id, sent); + updateTask( + currentTask.copyWith( + uploadedBytes: sent, + progress: progress, + speed: speed, + ), + ); + _emitProgress(task.id, progress); + }, + ); + } + } + + /// 发送进度更新 + void _emitProgress(String taskId, double progress) { + final controller = _progressControllers[taskId]; + if (controller != null && !controller.isClosed) { + controller.add(progress); + } + } + + /// 计算上传速度 + int _computeSpeed(String taskId, int uploadedBytes) { + final tracker = _speedTrackers[taskId]; + if (tracker == null) { + _speedTrackers[taskId] = _SpeedTracker(uploadedBytes); + return 0; + } + return tracker.update(uploadedBytes); + } + + /// 清理速度追踪器 + void _cleanSpeedTracker(String taskId) { + _speedTrackers.remove(taskId); + } +} + +/// 上传速度追踪器 +class _SpeedTracker { + int lastBytes; + DateTime lastTime; + + _SpeedTracker(this.lastBytes) : lastTime = DateTime.now(); + + int update(int currentBytes) { + final now = DateTime.now(); + final elapsed = now.difference(lastTime).inMilliseconds; + if (elapsed < 200) return 0; // 至少 200ms 间隔才计算 + + final bytesDelta = currentBytes - lastBytes; + final speed = (bytesDelta * 1000 / elapsed).round(); + + lastBytes = currentBytes; + lastTime = now; + return speed > 0 ? speed : 0; + } +} diff --git a/lib/services/user_setting_service.dart b/lib/services/user_setting_service.dart new file mode 100644 index 0000000..4fd57cb --- /dev/null +++ b/lib/services/user_setting_service.dart @@ -0,0 +1,178 @@ +import 'dart:typed_data'; +import '../data/models/user_setting_model.dart'; +import '../data/models/user_model.dart'; +import 'api_service.dart'; + +/// 用户设置服务 +class UserSettingService { + UserSettingService._internal(); + static final UserSettingService _instance = UserSettingService._internal(); + static UserSettingService get instance => _instance; + + /// 获取当前用户设置 + /// _parseResponse 已自动提取 data 字段,返回值即 json['data'] + Future getUserSetting() async { + final response = await ApiService.instance.get>( + '/user/setting', + ); + return UserSettingModel.fromJson(response); + } + + /// 更新用户设置(仅发送非null字段) + /// PATCH /user/setting 返回的 data 字段可能为 null,不应强转为 Map + Future updateUserSetting({ + String? nick, + bool? groupExpires, + String? language, + String? preferredTheme, + bool? versionRetentionEnabled, + List? versionRetentionExt, + int? versionRetentionMax, + String? currentPassword, + String? newPassword, + bool? twoFaEnabled, + String? twoFaCode, + bool? disableViewSync, + String? shareLinksInProfile, + }) async { + final data = {}; + if (nick != null) data['nick'] = nick; + if (groupExpires != null) data['group_expires'] = groupExpires; + if (language != null) data['language'] = language; + if (preferredTheme != null) data['preferred_theme'] = preferredTheme; + if (versionRetentionEnabled != null) { + data['version_retention_enabled'] = versionRetentionEnabled; + } + if (versionRetentionExt != null) { + data['version_retention_ext'] = versionRetentionExt; + } + if (versionRetentionMax != null) { + data['version_retention_max'] = versionRetentionMax; + } + if (currentPassword != null) data['current_password'] = currentPassword; + if (newPassword != null) data['new_password'] = newPassword; + if (twoFaEnabled != null) data['two_fa_enabled'] = twoFaEnabled; + if (twoFaCode != null) data['two_fa_code'] = twoFaCode; + if (disableViewSync != null) data['disable_view_sync'] = disableViewSync; + if (shareLinksInProfile != null) { + data['share_links_in_profile'] = shareLinksInProfile; + } + + // PATCH 响应的 data 字段通常为 null,使用 void 避免类型转换错误 + await ApiService.instance.patch( + '/user/setting', + data: data, + ); + } + + /// 更新头像(传图片二进制数据,传null则重置为Gravatar) + Future updateAvatar(Uint8List? imageBytes) async { + await ApiService.instance.put( + '/user/setting/avatar', + data: imageBytes, + isNoData: true, + ); + } + + /// 获取存储用量 + /// _parseResponse 已自动提取 data 字段 + Future getUserCapacity() async { + final response = await ApiService.instance.get>( + '/user/capacity', + ); + return UserCapacityModel.fromJson(response); + } + + /// 准备启用2FA(获取TOTP密钥) + /// _parseResponse 自动提取 data 字段,后端 data 是 TOTP secret 字符串 + Future prepare2FA() async { + return await ApiService.instance.get( + '/user/setting/2fa', + ); + } + + /// 启用2FA + Future enable2FA(String code) async { + await updateUserSetting(twoFaEnabled: true, twoFaCode: code); + } + + /// 禁用2FA + Future disable2FA(String code) async { + await updateUserSetting(twoFaEnabled: false, twoFaCode: code); + } + + /// 修改密码 + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + await updateUserSetting( + currentPassword: currentPassword, + newPassword: newPassword, + ); + } + + /// 修改昵称 + Future updateNick(String nick) async { + await updateUserSetting(nick: nick); + } + + /// 修改主题色偏好 + Future updatePreferredTheme(String themeColor) async { + await updateUserSetting(preferredTheme: themeColor); + } + + /// 修改语言偏好 + Future updateLanguage(String language) async { + await updateUserSetting(language: language); + } + + /// 获取当前用户信息(用于刷新用户资料) + /// _parseResponse 已自动提取 data 字段 + Future getCurrentUser() async { + final response = await ApiService.instance.get>( + '/session/user', + ); + return UserModel.fromJson(response); + } + + /// 撤销OAuth应用授权 + Future revokeOAuthGrant(String appId) async { + await ApiService.instance.delete( + '/session/oauth/grant/$appId', + ); + } + + /// 解绑OIDC提供商 + Future unlinkOpenId(int provider) async { + await ApiService.instance.post( + '/session/oidc/unlink', + data: {'provider': provider}, + ); + } + + /// 删除Passkey + Future deletePasskey(String passkeyId) async { + await ApiService.instance.delete( + '/user/authn', + queryParameters: {'id': passkeyId}, + ); + } + + /// 获取积分变动记录 + /// _parseResponse 已自动提取 data 字段 + Future getCreditChanges({ + int pageSize = 20, + String? nextPageToken, + }) async { + final queryParams = { + 'page_size': pageSize, + 'next_page_token': ?nextPageToken, + }; + final response = await ApiService.instance.get>( + '/user/creditChanges', + queryParameters: queryParams, + ); + return CreditChangeList.fromJson(response); + } +} diff --git a/lib/services/webdav_service.dart b/lib/services/webdav_service.dart new file mode 100644 index 0000000..d7d7627 --- /dev/null +++ b/lib/services/webdav_service.dart @@ -0,0 +1,73 @@ +import '../data/models/dav_account_model.dart'; +import 'api_service.dart'; +import '../core/utils/app_logger.dart'; + +/// WebDAV 服务 +class WebdavService { + /// 列出所有 WebDAV 账户 + Future> listAccounts({ + required int pageSize, + String? nextPageToken, + }) async { + final params = { + 'page_size': pageSize, + ...nextPageToken != null ? {'next_page_token': nextPageToken} : {}, + }; + + return await ApiService.instance + .get>('/devices/dav', queryParameters: params); + } + + /// 创建 WebDAV 账户 + Future createAccount({ + required String uri, + required String name, + bool? readonly, + bool? proxy, + bool? disableSysFiles, + }) async { + final data = { + 'uri': uri, + 'name': name, + ...readonly != null ? {'readonly': readonly} : {}, + ...proxy != null ? {'proxy': proxy} : {}, + ...disableSysFiles != null ? {'disable_sys_files': disableSysFiles} : {}, + }; + + final response = await ApiService.instance + .put>('/devices/dav', data: data); + // 已经经过 api_service.dart -> _parseResponse 处理过的数据, + // 直接不使用, 不需要 response['data'] 去取 + return DavAccountModel.fromJson(response); + } + + /// 更新 WebDAV 账户 + Future updateAccount({ + required String id, + String? uri, + String? name, + bool? readonly, + bool? proxy, + bool? disableSysFiles, + }) async { + final data = { + ...uri != null ? {'uri': uri} : {}, + ...name != null ? {'name': name} : {}, + ...readonly != null ? {'readonly': readonly} : {}, + ...proxy != null ? {'proxy': proxy} : {}, + ...disableSysFiles != null ? {'disable_sys_files': disableSysFiles} : {}, + }; + + final response = await ApiService.instance + .patch>('/devices/dav/$id', data: data); + AppLogger.d('更新 WebDAV 账户成功: $response'); + // 已经经过 api_service.dart -> _parseResponse 处理过的数据, + // 直接不使用, 不需要 response['data'] 去取 + return DavAccountModel.fromJson(response); + } + + /// 删除 WebDAV 账户 + Future deleteAccount(String id) async { + await ApiService.instance.delete('/devices/dav/$id', data: {}); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e5e61b4 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "cloudreve4_flutter") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.limo.cloudreve4_flutter") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..b370540 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,51 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_drop_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); + desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin"); + flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) open_file_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); + open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..646703a --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,34 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop + file_selector_linux + flutter_acrylic + media_kit_libs_linux + media_kit_video + open_file_linux + screen_retriever_linux + tray_manager + url_launcher_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..23cebd4 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "公云存储"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "公云存储"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..cda7331 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1815 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + animations: + dependency: "direct main" + description: + name: animations + sha256: "9cb469212ea51be27097f23b519d594c01171721347b55df9334fff653659e7f" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06" + url: "https://pub.dev" + source: hosted + version: "9.5.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + code_text_field: + dependency: "direct main" + description: + name: code_text_field + sha256: "0cbffbb2932cf82e1d022996388041de3493a476acad3fbb13e5917cac0fc5f2" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f + url: "https://pub.dev" + source: hosted + version: "0.7.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "2ccdf9db8fe4d9c5a75c122e6275674508fd0f0d49c827354967b8afcc56bbed" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_acrylic: + dependency: "direct main" + description: + name: flutter_acrylic + sha256: b3996dbde5abf5823cc9ead4cf2e5267c3181f15585fe47ce4dc4472e7ec827a + url: "https://pub.dev" + source: hosted + version: "1.1.4" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_single_instance: + dependency: "direct main" + description: + name: flutter_single_instance + sha256: "91c8a3dd16c5fc9ad8d8afc533c2f6b6318ca4905faf44f945b620f268eb094b" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_cloud: + dependency: transitive + description: + name: google_cloud + sha256: fbcde933b2d8600c3cdb2328f8f4c47628ec29a39e9cef85dee535c7868993c4 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: "2a8895c3885197f96bb2fd91ee0ae77b53ff3874c7b1f1eadb6566248e880958" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + grpc: + dependency: transitive + description: + name: grpc + sha256: "15227eeed339bd0ef5afe515cb791b2e4bec0711ab56f37cc44257bcfaedc4bf" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http2: + dependency: transitive + description: + name: http2 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.dev" + source: hosted + version: "6.8.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linked_scroll_controller: + dependency: transitive + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" + macos_window_utils: + dependency: transitive + description: + name: macos_window_utils + sha256: cb918e1ff0b31fdaa5cd8631eded7c24bd72e1025cf1f95c819e483f0057c652 + url: "https://pub.dev" + source: hosted + version: "1.9.1" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c + url: "https://pub.dev" + source: hosted + version: "2.3.2+8" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + oktoast: + dependency: "direct main" + description: + name: oktoast + sha256: f1366c5c793ddfb8f55bc6fc3e45db43c45debf173b765fb4c5ec096cbdeb84a + url: "https://pub.dev" + source: hosted + version: "3.4.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b + url: "https://pub.dev" + source: hosted + version: "3.5.11" + open_file_android: + dependency: transitive + description: + name: open_file_android + sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + open_file_ios: + dependency: transitive + description: + name: open_file_ios + sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f + url: "https://pub.dev" + source: hosted + version: "1.0.4" + open_file_linux: + dependency: transitive + description: + name: open_file_linux + sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550 + url: "https://pub.dev" + source: hosted + version: "0.0.5" + open_file_mac: + dependency: transitive + description: + name: open_file_mac + sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + open_file_platform_interface: + dependency: transitive + description: + name: open_file_platform_interface + sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_web: + dependency: transitive + description: + name: open_file_web + sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f + url: "https://pub.dev" + source: hosted + version: "0.0.4" + open_file_windows: + dependency: transitive + description: + name: open_file_windows + sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdfium_dart: + dependency: transitive + description: + name: pdfium_dart + sha256: a339ed67ff9bcca950481fceeeee68ffd602d2147e01d833e4254d84132a6bda + url: "https://pub.dev" + source: hosted + version: "0.2.1" + pdfium_flutter: + dependency: transitive + description: + name: pdfium_flutter + sha256: "97ccb5cf207b66ba5549f09047e935376d967c6311ec3c5314405d1d04569fd2" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + pdfrx: + dependency: "direct main" + description: + name: pdfrx + sha256: "0caa00aca2032cee5755873e88af849448534b95680895bfc57746dc3b4aea0d" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + pdfrx_engine: + dependency: transitive + description: + name: pdfrx_engine + sha256: a201b11e13b6c729d731ddc96ce06394cce8503c39e4c7e9d2fa51b3e7b351a5 + url: "https://pub.dev" + source: hosted + version: "0.4.2" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: f8a08a13fb8f0f8c590df89d745000bed44a673ed94bac846739e1a016875c21 + url: "https://pub.dev" + source: hosted + version: "2.5.7" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: c5fd83b0ae4d80be6eaedfad87aaefab8787b333b8ebd064b0e442a81006035b + url: "https://pub.dev" + source: hosted + version: "0.5.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.4 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..6cd6b60 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,183 @@ +name: cloudreve4_flutter +description: "CloudreveV4" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.3.0+1 + +environment: + sdk: ^3.11.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # 状态管理 + provider: ^6.1.1 + + # 网络请求 + dio: ^5.4.0 + http: ^1.1.0 + + # 本地存储 + shared_preferences: ^2.2.1 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # 国际化 + easy_localization: ^3.0.3 + + # UI组件 + photo_view: ^0.14.0 + + # 预览功能 + flutter_cache_manager: ^3.4.1 + cached_network_image: ^3.3.1 + pdfrx: ^2.2.24 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 # For video rendering. + media_kit_libs_video: ^1.0.7 # Native video dependencies. + code_text_field: ^1.1.0 + markdown_widget: ^2.3.2+8 + + # 文件选择 + file_picker: ^8.0.0 + + # 下载 + background_downloader: 9.5.4 + path_provider: ^2.1.1 + permission_handler: ^11.1.0 + url_launcher: ^6.2.1 + open_file: ^3.5.11 + + # 代码高亮 + flutter_highlight: ^0.7.0 + highlight: ^0.7.0 + + # 图标 + flutter_svg: ^2.0.9 + lucide_icons: ^0.257.0 + + # 动画 + animations: ^2.0.11 + + # 其他 + intl: ^0.20.2 + json_annotation: ^4.8.1 + package_info_plus: ^9.0.1 + oktoast: ^3.4.0 + logger: ^2.7.0 + image_picker: ^1.2.2 + qr_flutter: ^4.1.0 + + # 桌面端 + window_manager: ^0.5.0 + tray_manager: ^0.5.2 + flutter_single_instance: ^1.7.0 + path: ^1.9.1 + flutter_acrylic: ^1.1.4 + crypto: ^3.0.7 + desktop_drop: ^0.7.1 + cross_file: ^0.3.5+2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + build_runner: ^2.4.7 + json_serializable: ^6.7.1 + hive_generator: ^2.0.1 + flutter_launcher_icons: ^0.13.1 + +flutter_launcher_icons: + android: "launcher_icon" + ios: false + image_path: "icon_source.png" + min_sdk_android: 34 + web: + generate: true + windows: + generate: true + linux: + generate: true + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/app_logo.png + - assets/icons/ + fonts: + - family: SourceCodePro + fonts: + - asset: assets/fonts/SourceCodePro-Regular.ttf + - family: NotoSansSC + fonts: + - asset: assets/fonts/NotoSansSC-Medium.ttf + weight: 500 + - asset: assets/fonts/NotoSansSC-Bold.ttf + weight: 700 + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..e69de29 diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..70beef7 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..3f3b385 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..26a1b0b Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..3f3b385 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..26a1b0b Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..75c5301 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + 公云存储 + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..0241a0b --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "公云存储", + "short_name": "公云存储", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "公云存储", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..2bb0ebd --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,109 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(cloudreve4_flutter LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "cloudreve4_flutter") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +#if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) +# set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +#endif() + +set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..553ec2a --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,41 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopDropPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopDropPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterAcrylicPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterAcrylicPlugin")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..5f81a33 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,34 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop + file_selector_windows + flutter_acrylic + media_kit_libs_windows_video + media_kit_video + permission_handler_windows + screen_retriever_windows + tray_manager + url_launcher_windows + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..d0d3475 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.limo" "\0" + VALUE "FileDescription", "公云存储" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "公允存储" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.limo. All rights reserved." "\0" + VALUE "OriginalFilename", "cloudreve4_flutter.exe" "\0" + VALUE "ProductName", "公云存储" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..e3f0515 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"公云存储", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(false); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..4185bb6 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_