feat: add Flutter mobile app

This commit is contained in:
2026-08-18 01:07:44 +08:00
parent 38deb8d593
commit 301959b6ad
83 changed files with 10384 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# 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
+33
View File
@@ -0,0 +1,33 @@
# 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: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: android
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: ios
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
# 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'
+33
View File
@@ -0,0 +1,33 @@
# ImageFind Mobile
ImageFind 的 Flutter Android / iOS 客户端。客户端只连接后端 `/api/v1`,不依赖或复用 Web 前端代码。
## 本地运行
```powershell
C:\Users\nanxun\Documents\flutter\bin\flutter.bat --no-version-check pub get
C:\Users\nanxun\Documents\flutter\bin\flutter.bat --no-version-check run
```
首次启动后填写已开启“直接 Web/API 访问”的 ImageFind 地址。局域网地址可以使用 HTTP,公网地址必须使用 HTTPS。
## 已实现
- 安全会话:服务发现、首次设置、登录、CSRF、加密凭据存储
- 自适应界面:手机底栏、平板导航 Rail、宽屏双栏
- 首页、搜索、资料库、人物、合集、个人与服务工作台
- 文字搜索和图片 multipart 搜索
- 原始 Range 播放、HLS 回退、进度同步、逐字稿和收藏时刻
- 设备视频分块上传及已接收分块续传
- 本机 `.part` Range 续传下载、Drift 状态持久化和离线播放
- SSE 断线重连及核心 Riverpod 数据刷新
## 验证
```powershell
C:\Users\nanxun\Documents\flutter\bin\flutter.bat --no-version-check analyze
C:\Users\nanxun\Documents\flutter\bin\flutter.bat --no-version-check test
C:\Users\nanxun\Documents\flutter\bin\flutter.bat --no-version-check build apk --debug
```
Android APK 输出到 `build/app/outputs/flutter-apk/app-debug.apk`。iOS 编译、Simulator 截图与签名必须在 macOS 上完成。
+29
View File
@@ -0,0 +1,29 @@
# 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:
curly_braces_in_flow_control_structures: false
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+47
View File
@@ -0,0 +1,47 @@
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")
}
android {
namespace = "com.imagefind.mobile"
// Flutter 3.41 defaults to API 37, while the stable Android SDK installed
// for this project is API 36. Keep the app on the stable platform until
// Android's API 37 package leaves the `android-37.0` preview naming scheme.
compileSdk = 36
ndkVersion = flutter.ndkVersion
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.imagefind.mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="ImageFind"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.imagefind.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+620
View File
@@ -0,0 +1,620 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.imagefind.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ImageFind</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ImageFind</string>
<key>NSLocalNetworkUsageDescription</key>
<string>ImageFind 需要连接你在局域网中的私人媒体服务器。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于选择查询图片或上传到你的私人媒体库。</string>
<key>NSCameraUsageDescription</key>
<string>用于拍摄查询图片,在自己的媒体库中查找相似画面。</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'src/app.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
runApp(const ProviderScope(child: ImageFindApp()));
}
+638
View File
@@ -0,0 +1,638 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'models.dart';
class ApiException implements Exception {
const ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => message;
}
class UploadChunkSlice {
const UploadChunkSlice(this.index, this.offset, this.length);
final int index;
final int offset;
final int length;
}
List<UploadChunkSlice> pendingUploadChunks({
required int sizeBytes,
required int chunkSize,
Set<int> received = const {},
}) {
if (sizeBytes <= 0 || chunkSize <= 0) return const [];
final total = (sizeBytes / chunkSize).ceil();
return [
for (var index = 0; index < total; index++)
if (!received.contains(index))
UploadChunkSlice(
index,
index * chunkSize,
(sizeBytes - index * chunkSize).clamp(0, chunkSize),
),
];
}
Stream<Map<String, dynamic>> decodeServerEvents(
Stream<List<int>> bytes,
) async* {
final lines = bytes.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in lines) {
if (!line.startsWith('data:')) continue;
final raw = line.substring(5).trim();
if (raw.isEmpty) continue;
final decoded = jsonDecode(raw);
if (decoded is Map) yield Map<String, dynamic>.from(decoded);
}
}
class ImageFindApi {
ImageFindApi(this._storage) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (_cookie?.isNotEmpty == true)
options.headers[HttpHeaders.cookieHeader] = _cookie;
if (!{
'GET',
'HEAD',
'OPTIONS',
}.contains(options.method.toUpperCase()) &&
_csrf?.isNotEmpty == true) {
options.headers['X-CSRF-Token'] = _csrf;
}
handler.next(options);
},
onError: (error, handler) => handler.next(error),
),
);
}
static const _serverKey = 'imagefind.server';
static const _cookieKey = 'imagefind.session.cookie';
static const _csrfKey = 'imagefind.session.csrf';
final FlutterSecureStorage _storage;
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
headers: {HttpHeaders.acceptHeader: 'application/json'},
),
);
String? _apiRoot;
String? _serverRoot;
String? _cookie;
String? _csrf;
String? get serverRoot => _serverRoot;
bool get isConfigured => _apiRoot != null;
Map<String, String> get mediaHeaders => _cookie?.isNotEmpty == true
? {HttpHeaders.cookieHeader: _cookie!}
: const {};
Future<bool> restore() async {
final server = await _storage.read(key: _serverKey);
if (server == null || server.isEmpty) return false;
_configure(server);
_cookie = await _storage.read(key: _cookieKey);
_csrf = await _storage.read(key: _csrfKey);
return _cookie?.isNotEmpty == true;
}
Future<ServerStatus> connect(String rawUrl) async {
final uri = _validateServer(rawUrl);
_configure(uri.toString());
try {
final response = await _dio.get<Map<String, dynamic>>('$_apiRoot/status');
final status = ServerStatus.fromJson(response.data ?? const {});
if (status.accessMode != 'direct') {
throw const ApiException(
'这是 fnOS 网关专属地址。请在 ImageFind 设置中开启直接 Web/API 访问后,填写直连地址。',
);
}
await _storage.write(key: _serverKey, value: _serverRoot);
return status;
} on DioException catch (error) {
throw _mapError(error, connection: true);
}
}
Future<void> login(
String password, {
bool setup = false,
bool remember = true,
}) async {
_requireConfigured();
try {
final response = await _dio.post<Map<String, dynamic>>(
'$_apiRoot/${setup ? 'setup' : 'auth/login'}',
data: {'password': password, 'remember_device': remember},
);
final data = response.data ?? const {};
_csrf = data['csrf_token']?.toString();
_cookie = _extractCookie(response.headers.map['set-cookie']);
if (_cookie == null || _csrf == null)
throw const ApiException('服务器未返回有效会话,请检查反向代理 Cookie 配置。');
await _storage.write(key: _cookieKey, value: _cookie);
await _storage.write(key: _csrfKey, value: _csrf);
} on DioException catch (error) {
throw _mapError(error);
}
}
Future<bool> verifySession() async {
if (_apiRoot == null || _cookie == null) return false;
try {
final response = await _dio.get<Map<String, dynamic>>(
'$_apiRoot/auth/me',
);
final csrf = response.data?['csrf']?.toString();
if (csrf?.isNotEmpty == true) {
_csrf = csrf;
await _storage.write(key: _csrfKey, value: csrf);
}
return response.data?['authenticated'] == true;
} catch (_) {
return false;
}
}
Future<void> logout() async {
try {
if (_apiRoot != null && _cookie != null)
await _dio.post<void>('$_apiRoot/auth/logout');
} catch (_) {
// Local session must still be cleared if the server is unavailable.
}
_cookie = null;
_csrf = null;
await _storage.delete(key: _cookieKey);
await _storage.delete(key: _csrfKey);
}
Future<HomeFeed> home() async => HomeFeed.fromJson(await getMap('home'));
Future<List<VideoRecord>> videos({Map<String, dynamic>? query}) async =>
(await getList(
'videos',
query: query,
)).map(VideoRecord.fromJson).toList();
Future<List<CollectionRecord>> collections() async =>
(await getList('collections')).map(CollectionRecord.fromJson).toList();
Future<List<PersonRecord>> people() async =>
(await getList('people')).map(PersonRecord.fromJson).toList();
Future<List<SearchHit>> search(String text, {String? imageId}) async {
final data = await postMap(
'search',
data: {
'text': text.trim().isEmpty ? null : text.trim(),
'image_id': imageId,
'limit': 60,
},
);
final raw = data['items'] ?? data['results'] ?? data['hits'] ?? const [];
return asJsonList(raw).map(SearchHit.fromJson).toList();
}
Future<String> uploadQueryImage(String path) async {
_requireConfigured();
final extension = path.split('.').last.toLowerCase();
final mime = switch (extension) {
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
_ => 'image/jpeg',
};
try {
final response = await _dio.post<Map<String, dynamic>>(
'$_apiRoot/query-images',
data: FormData.fromMap({
'file': await MultipartFile.fromFile(
path,
filename: path.split(Platform.pathSeparator).last,
contentType: DioMediaType.parse(mime),
),
}),
options: Options(sendTimeout: const Duration(minutes: 2)),
);
final id = response.data?['id']?.toString();
if (id == null || id.isEmpty) {
throw const ApiException('服务器未返回查询图片编号。');
}
return id;
} on DioException catch (error) {
throw _mapError(error);
}
}
Future<List<Map<String, dynamic>>> writableSources() async =>
(await getList('sources'))
.where(
(item) => item['writable'] == true || item['read_only'] != true,
)
.toList();
Future<String> uploadVideoFile({
required String path,
required String filename,
required int sizeBytes,
required String sourceId,
String relativePath = '',
void Function(String uploadId)? onCreated,
void Function(int sent, int total)? onProgress,
}) async {
final created = await postMap(
'uploads',
data: {
'source_id': sourceId,
'relative_path': relativePath,
'filename': filename,
'size_bytes': sizeBytes,
'conflict': 'rename',
},
);
final uploadId = '${created['id'] ?? ''}';
if (uploadId.isEmpty) throw const ApiException('服务器未能创建上传任务。');
onCreated?.call(uploadId);
await _sendUploadChunks(
uploadId: uploadId,
path: path,
sizeBytes: sizeBytes,
chunkSize: _asPositiveInt(created['chunk_size'], 4 * 1024 * 1024),
received: _intSet(created['received_chunks']),
onProgress: onProgress,
);
return uploadId;
}
Future<void> resumeVideoUpload({
required String uploadId,
required String path,
required int sizeBytes,
void Function(int sent, int total)? onProgress,
}) async {
final rows = await getList('uploads');
final upload = rows
.where((item) => '${item['id']}' == uploadId)
.firstOrNull;
if (upload == null) {
throw const ApiException('服务器上的上传任务已过期,请重新选择视频。');
}
if ('${upload['status']}' != 'receiving') {
throw ApiException('当前任务状态为 ${upload['status']},不能继续上传分块。');
}
await _sendUploadChunks(
uploadId: uploadId,
path: path,
sizeBytes: sizeBytes,
chunkSize: _asPositiveInt(upload['chunk_size'], 4 * 1024 * 1024),
received: _intSet(upload['received_chunks']),
onProgress: onProgress,
);
}
Future<void> _sendUploadChunks({
required String uploadId,
required String path,
required int sizeBytes,
required int chunkSize,
required Set<int> received,
void Function(int sent, int total)? onProgress,
}) async {
_requireConfigured();
final file = File(path);
if (!await file.exists() || await file.length() != sizeBytes) {
throw const ApiException('所选视频已移动或大小发生变化,请重新选择。');
}
var sent = received.fold<int>(0, (sum, index) {
final start = index * chunkSize;
return sum + (sizeBytes - start).clamp(0, chunkSize);
});
onProgress?.call(sent, sizeBytes);
final handle = await file.open();
try {
for (final chunk in pendingUploadChunks(
sizeBytes: sizeBytes,
chunkSize: chunkSize,
received: received,
)) {
await handle.setPosition(chunk.offset);
final bytes = await handle.read(chunk.length);
if (bytes.length != chunk.length) {
throw const ApiException('读取视频分块失败。');
}
try {
await _dio.put<Map<String, dynamic>>(
'$_apiRoot/uploads/$uploadId/chunks/${chunk.index}',
data: Stream<Uint8List>.value(Uint8List.fromList(bytes)),
options: Options(
contentType: 'application/octet-stream',
headers: {HttpHeaders.contentLengthHeader: chunk.length},
sendTimeout: const Duration(minutes: 10),
),
);
} on DioException catch (error) {
throw _mapError(error);
}
sent += chunk.length;
onProgress?.call(sent, sizeBytes);
}
final completed = await postMap('uploads/$uploadId/complete');
final missing = _intSet(completed['missing_chunks']);
if (missing.isNotEmpty) {
throw ApiException('仍有 ${missing.length} 个分块未上传,请重试。');
}
} finally {
await handle.close();
}
}
Future<void> downloadVideoToFile({
required VideoRecord video,
required String partialPath,
required int offset,
void Function(int received, int? total)? onProgress,
}) async {
_requireConfigured();
final path = video.downloadUrl?.isNotEmpty == true
? video.downloadUrl!
: '/api/v1/videos/${video.id}/download';
final uri = absoluteUri(path);
try {
final response = await _dio.get<ResponseBody>(
uri.toString(),
options: Options(
responseType: ResponseType.stream,
headers: offset > 0
? {HttpHeaders.rangeHeader: 'bytes=$offset-'}
: null,
receiveTimeout: const Duration(hours: 6),
),
);
final append =
offset > 0 && response.statusCode == HttpStatus.partialContent;
final start = append ? offset : 0;
final contentRange = response.headers.value(
HttpHeaders.contentRangeHeader,
);
final rangeTotal = contentRange?.split('/').last;
final contentLength = int.tryParse(
response.headers.value(HttpHeaders.contentLengthHeader) ?? '',
);
final total =
int.tryParse(rangeTotal ?? '') ??
(contentLength == null ? null : start + contentLength);
final sink = File(
partialPath,
).openWrite(mode: append ? FileMode.append : FileMode.write);
var received = start;
try {
await for (final chunk in response.data!.stream) {
sink.add(chunk);
received += chunk.length;
onProgress?.call(received, total);
}
} finally {
await sink.flush();
await sink.close();
}
if (total != null && received < total) {
throw ApiException('下载中断:已接收 $received / $total 字节。');
}
} on DioException catch (error) {
throw _mapError(error);
}
}
Stream<Map<String, dynamic>> events() async* {
_requireConfigured();
try {
final response = await _dio.get<ResponseBody>(
'$_apiRoot/events',
options: Options(
responseType: ResponseType.stream,
receiveTimeout: Duration.zero,
headers: {HttpHeaders.acceptHeader: 'text/event-stream'},
),
);
yield* decodeServerEvents(response.data!.stream.cast<List<int>>());
} on DioException catch (error) {
throw _mapError(error);
}
}
Future<List<TranscriptLine>> transcript(String videoId) async {
final data = await getDynamic('videos/$videoId/transcript');
final raw = data is Map ? data['segments'] ?? data['items'] : data;
return asJsonList(raw).map(TranscriptLine.fromJson).toList();
}
Future<List<MarkerRecord>> markers(String videoId) async => (await getList(
'videos/$videoId/markers',
)).map(MarkerRecord.fromJson).toList();
Future<void> addMarker(String videoId, int positionMs, {String? title}) =>
postMap(
'videos/$videoId/markers',
data: {'position_ms': positionMs, 'title': title},
);
Future<void> deleteMarker(String videoId, String markerId) =>
delete('videos/$videoId/markers/$markerId');
Future<void> updateVideoState(String videoId, Map<String, dynamic> data) =>
patchMap('videos/$videoId/state', data: data);
Future<void> updatePreferences(Map<String, dynamic> data) =>
patchMap('preferences', data: data);
Uri absoluteUri(String? path) {
if (path == null || path.isEmpty) return Uri();
final parsed = Uri.tryParse(path);
if (parsed?.hasScheme == true) return parsed!;
return Uri.parse(
_serverRoot!,
).resolve(path.startsWith('/') ? path.substring(1) : path);
}
Uri streamUri(VideoRecord video, {bool proxy = false}) {
final path = video.playbackUrl?.isNotEmpty == true
? video.playbackUrl!
: '/api/v1/videos/${video.id}/stream';
final uri = absoluteUri(path);
return proxy
? uri.replace(
queryParameters: {...uri.queryParameters, 'mode': 'proxy'},
)
: uri;
}
Uri thumbnailUri(String? path) => absoluteUri(path);
Future<dynamic> getDynamic(String path, {Map<String, dynamic>? query}) async {
_requireConfigured();
try {
final response = await _dio.get<dynamic>(
'$_apiRoot/$path',
queryParameters: query,
);
return response.data;
} on DioException catch (error) {
throw _mapError(error);
}
}
Future<Map<String, dynamic>> getMap(
String path, {
Map<String, dynamic>? query,
}) async => asJsonMap(await getDynamic(path, query: query));
Future<List<Map<String, dynamic>>> getList(
String path, {
Map<String, dynamic>? query,
}) async => asJsonList(await getDynamic(path, query: query));
Future<Map<String, dynamic>> postMap(String path, {Object? data}) =>
_write('POST', path, data);
Future<Map<String, dynamic>> patchMap(String path, {Object? data}) =>
_write('PATCH', path, data);
Future<Map<String, dynamic>> putMap(String path, {Object? data}) =>
_write('PUT', path, data);
Future<Map<String, dynamic>> _write(
String method,
String path,
Object? data,
) async {
_requireConfigured();
try {
final response = await _dio.request<dynamic>(
'$_apiRoot/$path',
data: data,
options: Options(method: method),
);
return asJsonMap(response.data);
} on DioException catch (error) {
throw _mapError(error);
}
}
Future<void> delete(String path) async {
_requireConfigured();
try {
await _dio.delete<void>('$_apiRoot/$path');
} on DioException catch (error) {
throw _mapError(error);
}
}
Uri _validateServer(String raw) {
var value = raw.trim();
if (!value.contains('://')) value = 'http://$value';
final uri = Uri.tryParse(value);
if (uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty) {
throw const ApiException('请输入有效的 ImageFind 服务器地址。');
}
if (uri.scheme == 'http' && !_isPrivateHost(uri.host)) {
throw const ApiException(
'公网地址必须使用 HTTPS;HTTP 只允许局域网、localhost 或 .local 地址。',
);
}
return _cleanUri(uri);
}
void _configure(String raw) {
final uri = _cleanUri(Uri.parse(raw));
var path = uri.path.replaceAll(RegExp(r'/+$'), '');
if (path.endsWith('/api/v1')) path = path.substring(0, path.length - 7);
if (path.endsWith('/api')) path = path.substring(0, path.length - 4);
_serverRoot = _cleanUri(
uri.replace(path: path.isEmpty ? '/' : '$path/'),
).toString();
_apiRoot = Uri.parse(
_serverRoot!,
).resolve('api/v1').toString().replaceAll(RegExp(r'/+$'), '');
}
void _requireConfigured() {
if (_apiRoot == null) throw const ApiException('尚未连接 ImageFind 服务器。');
}
String? _extractCookie(List<String>? values) {
if (values == null) return null;
for (final value in values) {
final first = value.split(';').first.trim();
if (first.startsWith('imagefind_session=')) return first;
}
return null;
}
ApiException _mapError(DioException error, {bool connection = false}) {
final status = error.response?.statusCode;
final data = error.response?.data;
String? detail;
if (data is Map) {
final raw = data['detail'] ?? data['message'];
detail = raw is Map
? raw['message']?.toString() ?? jsonEncode(raw)
: raw?.toString();
}
if (detail?.isNotEmpty == true)
return ApiException(detail!, statusCode: status);
if (status == 401)
return const ApiException('登录已失效,请重新登录。', statusCode: 401);
if (status == 403)
return const ApiException('当前操作未通过安全校验,请重新登录后再试。', statusCode: 403);
if (status == 429)
return const ApiException('登录尝试过于频繁,请稍后再试。', statusCode: 429);
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const ApiException('连接超时,请确认 NAS 在线且地址可从当前网络访问。');
}
if (connection || error.type == DioExceptionType.connectionError) {
return const ApiException('无法连接服务器,请检查地址、网络和 HTTPS 证书。');
}
return ApiException(
'请求失败${status == null ? '' : 'HTTP $status'},请稍后重试。',
statusCode: status,
);
}
bool _isPrivateHost(String host) {
final lower = host.toLowerCase();
if (lower == 'localhost' || lower.endsWith('.local')) return true;
final ip = InternetAddress.tryParse(host);
if (ip == null || ip.type != InternetAddressType.IPv4) return false;
final parts = host.split('.').map(int.parse).toList();
return parts[0] == 10 ||
parts[0] == 127 ||
(parts[0] == 192 && parts[1] == 168) ||
(parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31);
}
static int _asPositiveInt(dynamic value, int fallback) {
final parsed = value is int ? value : int.tryParse('$value');
return parsed != null && parsed > 0 ? parsed : fallback;
}
static Set<int> _intSet(dynamic value) => value is List
? value.map((item) => int.tryParse('$item')).whereType<int>().toSet()
: <int>{};
static Uri _cleanUri(Uri uri) => Uri(
scheme: uri.scheme,
userInfo: uri.userInfo,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: uri.path,
);
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'models.dart';
import 'screens/auth_screens.dart';
import 'screens/core_screens.dart';
import 'screens/player_screen.dart';
import 'screens/shell.dart';
import 'screens/workbench_screens.dart';
import 'state.dart';
import 'theme.dart';
class ImageFindApp extends ConsumerStatefulWidget {
const ImageFindApp({super.key});
@override
ConsumerState<ImageFindApp> createState() => _ImageFindAppState();
}
class _ImageFindAppState extends ConsumerState<ImageFindApp> {
late final GoRouter _router = GoRouter(
initialLocation: '/splash',
routes: [
GoRoute(path: '/splash', builder: (_, _) => const SplashScreen()),
GoRoute(path: '/connect', builder: (_, _) => const ConnectScreen()),
GoRoute(path: '/login', builder: (_, _) => const LoginScreen()),
GoRoute(
path: '/home',
builder: (_, _) => const AppShell(index: 0, child: HomeScreen()),
),
GoRoute(
path: '/search',
builder: (_, _) => const AppShell(index: 1, child: SearchScreen()),
),
GoRoute(
path: '/library',
builder: (_, _) => const AppShell(index: 2, child: LibraryScreen()),
),
GoRoute(
path: '/profile',
builder: (_, _) => const AppShell(index: 3, child: ProfileScreen()),
),
GoRoute(
path: '/player/:id',
builder: (_, state) => PlayerScreen(
videoId: state.pathParameters['id']!,
initialVideo: state.extra is VideoRecord
? state.extra! as VideoRecord
: null,
startMs: int.tryParse(state.uri.queryParameters['t'] ?? '') ?? 0,
),
),
GoRoute(path: '/workbench', builder: (_, _) => const WorkbenchScreen()),
GoRoute(
path: '/manage/:section',
builder: (_, state) =>
ManagementSectionScreen(section: state.pathParameters['section']!),
),
GoRoute(
path: '/collection/:id',
builder: (_, state) => CollectionDetailScreen(
id: state.pathParameters['id']!,
initial: state.extra is CollectionRecord
? state.extra! as CollectionRecord
: null,
),
),
GoRoute(
path: '/person/:id',
builder: (_, state) => PersonDetailScreen(
id: state.pathParameters['id']!,
initial: state.extra is PersonRecord
? state.extra! as PersonRecord
: null,
),
),
],
redirect: (context, state) {
final session = ref.read(sessionProvider);
final path = state.matchedLocation;
final authPath =
path == '/splash' || path == '/connect' || path == '/login';
switch (session.stage) {
case SessionStage.booting:
return path == '/splash' ? null : '/splash';
case SessionStage.disconnected:
return path == '/connect' ? null : '/connect';
case SessionStage.login:
case SessionStage.setup:
return path == '/login' ? null : '/login';
case SessionStage.authenticated:
return authPath ? '/home' : null;
}
},
);
@override
Widget build(BuildContext context) {
ref.listen<SessionState>(sessionProvider, (_, _) => _router.refresh());
ref.watch(eventSyncProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'ImageFind',
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: buildTheme(Brightness.light, TargetPlatform.android),
darkTheme: buildTheme(Brightness.dark, TargetPlatform.android),
routerConfig: _router,
builder: (context, child) {
final platform = Theme.of(context).platform;
final brightness = Theme.of(context).brightness;
return Theme(
data: buildTheme(brightness, platform),
child: child ?? const SizedBox.shrink(),
);
},
);
}
}
+291
View File
@@ -0,0 +1,291 @@
import 'dart:convert';
class ServerStatus {
const ServerStatus({
required this.configured,
required this.version,
required this.accessMode,
});
final bool configured;
final String version;
final String accessMode;
factory ServerStatus.fromJson(Map<String, dynamic> json) => ServerStatus(
configured: json['configured'] == true,
version: '${json['version'] ?? ''}',
accessMode: '${json['access_mode'] ?? 'direct'}',
);
}
class VideoRecord {
const VideoRecord({
required this.id,
required this.title,
required this.durationMs,
this.width = 0,
this.height = 0,
this.series = '',
this.sourceName = '',
this.thumbnailUrl,
this.playbackUrl,
this.downloadUrl,
this.progressMs = 0,
this.favorited = false,
this.completed = false,
this.tags = const [],
});
final String id;
final String title;
final int durationMs;
final int width;
final int height;
final String series;
final String sourceName;
final String? thumbnailUrl;
final String? playbackUrl;
final String? downloadUrl;
final int progressMs;
final bool favorited;
final bool completed;
final List<String> tags;
factory VideoRecord.fromJson(Map<String, dynamic> json) {
final metadata = json['metadata'] is Map
? Map<String, dynamic>.from(json['metadata'] as Map)
: const <String, dynamic>{};
final rawTags = json['tags'] ?? metadata['tags'];
return VideoRecord(
id: '${json['id'] ?? ''}',
title:
'${json['title'] ?? metadata['title'] ?? json['display_name'] ?? '未命名视频'}',
durationMs: _asInt(json['duration_ms']),
width: _asInt(json['width']),
height: _asInt(json['height']),
series:
'${json['series'] ?? metadata['series'] ?? json['collection_name'] ?? ''}',
sourceName: '${json['source_name'] ?? json['source_label'] ?? ''}',
thumbnailUrl: json['thumbnail_url']?.toString(),
playbackUrl: json['playback_url']?.toString(),
downloadUrl: json['download_url']?.toString(),
progressMs: _asInt(json['progress_ms']),
favorited: json['favorited'] == true || json['favorite'] == true,
completed: json['completed'] == true,
tags: rawTags is List
? rawTags
.map((e) => e is Map ? '${e['name'] ?? ''}' : '$e')
.where((e) => e.isNotEmpty)
.toList()
: const [],
);
}
String get durationLabel => formatDuration(durationMs);
String get resolutionLabel => width >= 3840
? '4K'
: width >= 1920
? '1080p'
: width > 0
? '${width}p'
: '未知画质';
VideoRecord copyWith({bool? favorited, int? progressMs, bool? completed}) =>
VideoRecord(
id: id,
title: title,
durationMs: durationMs,
width: width,
height: height,
series: series,
sourceName: sourceName,
thumbnailUrl: thumbnailUrl,
playbackUrl: playbackUrl,
downloadUrl: downloadUrl,
progressMs: progressMs ?? this.progressMs,
favorited: favorited ?? this.favorited,
completed: completed ?? this.completed,
tags: tags,
);
}
class CollectionRecord {
const CollectionRecord({
required this.id,
required this.name,
this.description = '',
this.videoCount = 0,
this.thumbnailUrl,
});
final String id;
final String name;
final String description;
final int videoCount;
final String? thumbnailUrl;
factory CollectionRecord.fromJson(Map<String, dynamic> json) =>
CollectionRecord(
id: '${json['id'] ?? ''}',
name: '${json['name'] ?? '未命名合集'}',
description: '${json['description'] ?? ''}',
videoCount: _asInt(json['video_count']),
thumbnailUrl: json['thumbnail_url']?.toString(),
);
}
class PersonRecord {
const PersonRecord({
required this.id,
required this.name,
this.faceCount = 0,
this.isNamed = false,
this.thumbnailUrl,
});
final String id;
final String name;
final int faceCount;
final bool isNamed;
final String? thumbnailUrl;
factory PersonRecord.fromJson(Map<String, dynamic> json) => PersonRecord(
id: '${json['id'] ?? ''}',
name: '${json['name'] ?? '待命名人物'}',
faceCount: _asInt(json['face_count'] ?? json['video_count']),
isNamed: json['is_named'] == true,
thumbnailUrl: json['thumbnail_url']?.toString(),
);
}
class SearchHit {
const SearchHit({
required this.video,
required this.positionMs,
required this.evidence,
required this.kind,
this.thumbnailUrl,
});
final VideoRecord video;
final int positionMs;
final String evidence;
final String kind;
final String? thumbnailUrl;
factory SearchHit.fromJson(Map<String, dynamic> json) {
final videoJson = json['video'] is Map
? Map<String, dynamic>.from(json['video'] as Map)
: json;
return SearchHit(
video: VideoRecord.fromJson(videoJson),
positionMs: _asInt(
json['timestamp_ms'] ?? json['position_ms'] ?? json['start_ms'],
),
evidence:
'${json['evidence'] ?? json['text'] ?? json['raw_text'] ?? json['matched_text'] ?? ''}',
kind:
'${json['recognition_type'] ?? json['kind'] ?? json['match_type'] ?? 'metadata'}',
thumbnailUrl: (json['thumbnail_url'] ?? videoJson['thumbnail_url'])
?.toString(),
);
}
}
class TranscriptLine {
const TranscriptLine({
required this.startMs,
required this.endMs,
required this.text,
});
final int startMs;
final int endMs;
final String text;
factory TranscriptLine.fromJson(Map<String, dynamic> json) => TranscriptLine(
startMs: _asInt(json['start_ms']),
endMs: _asInt(json['end_ms']),
text: '${json['raw_text'] ?? json['text'] ?? ''}',
);
}
class MarkerRecord {
const MarkerRecord({
required this.id,
required this.positionMs,
required this.title,
});
final String id;
final int positionMs;
final String title;
factory MarkerRecord.fromJson(Map<String, dynamic> json) => MarkerRecord(
id: '${json['id'] ?? ''}',
positionMs: _asInt(json['position_ms']),
title: '${json['title'] ?? '收藏时刻'}',
);
}
class HomeFeed {
const HomeFeed({
this.recent = const [],
this.continueWatching = const [],
this.collections = const [],
this.people = const [],
this.unorganized = const [],
});
final List<VideoRecord> recent;
final List<VideoRecord> continueWatching;
final List<CollectionRecord> collections;
final List<PersonRecord> people;
final List<VideoRecord> unorganized;
factory HomeFeed.fromJson(Map<String, dynamic> json) {
List<Map<String, dynamic>> items(dynamic value) {
final raw = value is Map ? value['items'] : value;
return raw is List
? raw
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: const [];
}
return HomeFeed(
recent: items(json['recent']).map(VideoRecord.fromJson).toList(),
continueWatching: items(
json['continue_watching'] ?? json['continue'],
).map(VideoRecord.fromJson).toList(),
collections: items(
json['collections'],
).map(CollectionRecord.fromJson).toList(),
people: items(json['people']).map(PersonRecord.fromJson).toList(),
unorganized: items(
json['unorganized'],
).map(VideoRecord.fromJson).toList(),
);
}
}
int _asInt(dynamic value) => value is int
? value
: value is num
? value.round()
: int.tryParse('$value') ?? 0;
String formatDuration(int milliseconds) {
final total = (milliseconds / 1000).floor().clamp(0, 359999);
final hours = total ~/ 3600;
final minutes = (total % 3600) ~/ 60;
final seconds = total % 60;
if (hours > 0)
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
Map<String, dynamic> asJsonMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
if (value is String && value.isNotEmpty)
return Map<String, dynamic>.from(jsonDecode(value) as Map);
return <String, dynamic>{};
}
List<Map<String, dynamic>> asJsonList(dynamic value) {
final raw = value is Map && value['items'] is List ? value['items'] : value;
return raw is List
? raw.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const [];
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'offline_database.g.dart';
class OfflineEntries extends Table {
TextColumn get videoId => text()();
TextColumn get title => text()();
TextColumn get localPath => text()();
TextColumn get partialPath => text().nullable()();
IntColumn get bytesDownloaded => integer().withDefault(const Constant(0))();
IntColumn get totalBytes => integer().nullable()();
TextColumn get status => text().withDefault(const Constant('queued'))();
TextColumn get error => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column<Object>> get primaryKey => {videoId};
}
@DriftDatabase(tables: [OfflineEntries])
final class OfflineDatabase extends _$OfflineDatabase {
OfflineDatabase([QueryExecutor? executor])
: super(executor ?? driftDatabase(name: 'imagefind_offline'));
@override
int get schemaVersion => 1;
Stream<List<OfflineEntry>> watchDownloads() => (select(
offlineEntries,
)..orderBy([(row) => OrderingTerm.desc(row.updatedAt)])).watch();
Future<void> saveDownload(OfflineEntriesCompanion value) =>
into(offlineEntries).insertOnConflictUpdate(value);
Future<void> removeDownload(String videoId) => (delete(
offlineEntries,
)..where((row) => row.videoId.equals(videoId))).go();
Future<OfflineEntry?> downloadFor(String videoId) => (select(
offlineEntries,
)..where((row) => row.videoId.equals(videoId))).getSingleOrNull();
}
+879
View File
@@ -0,0 +1,879 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'offline_database.dart';
// ignore_for_file: type=lint
class $OfflineEntriesTable extends OfflineEntries
with TableInfo<$OfflineEntriesTable, OfflineEntry> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$OfflineEntriesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _videoIdMeta = const VerificationMeta(
'videoId',
);
@override
late final GeneratedColumn<String> videoId = GeneratedColumn<String>(
'video_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _titleMeta = const VerificationMeta('title');
@override
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _localPathMeta = const VerificationMeta(
'localPath',
);
@override
late final GeneratedColumn<String> localPath = GeneratedColumn<String>(
'local_path',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _partialPathMeta = const VerificationMeta(
'partialPath',
);
@override
late final GeneratedColumn<String> partialPath = GeneratedColumn<String>(
'partial_path',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _bytesDownloadedMeta = const VerificationMeta(
'bytesDownloaded',
);
@override
late final GeneratedColumn<int> bytesDownloaded = GeneratedColumn<int>(
'bytes_downloaded',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _totalBytesMeta = const VerificationMeta(
'totalBytes',
);
@override
late final GeneratedColumn<int> totalBytes = GeneratedColumn<int>(
'total_bytes',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _statusMeta = const VerificationMeta('status');
@override
late final GeneratedColumn<String> status = GeneratedColumn<String>(
'status',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('queued'),
);
static const VerificationMeta _errorMeta = const VerificationMeta('error');
@override
late final GeneratedColumn<String> error = GeneratedColumn<String>(
'error',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
'updatedAt',
);
@override
late final GeneratedColumn<DateTime> updatedAt = GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
@override
List<GeneratedColumn> get $columns => [
videoId,
title,
localPath,
partialPath,
bytesDownloaded,
totalBytes,
status,
error,
updatedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'offline_entries';
@override
VerificationContext validateIntegrity(
Insertable<OfflineEntry> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('video_id')) {
context.handle(
_videoIdMeta,
videoId.isAcceptableOrUnknown(data['video_id']!, _videoIdMeta),
);
} else if (isInserting) {
context.missing(_videoIdMeta);
}
if (data.containsKey('title')) {
context.handle(
_titleMeta,
title.isAcceptableOrUnknown(data['title']!, _titleMeta),
);
} else if (isInserting) {
context.missing(_titleMeta);
}
if (data.containsKey('local_path')) {
context.handle(
_localPathMeta,
localPath.isAcceptableOrUnknown(data['local_path']!, _localPathMeta),
);
} else if (isInserting) {
context.missing(_localPathMeta);
}
if (data.containsKey('partial_path')) {
context.handle(
_partialPathMeta,
partialPath.isAcceptableOrUnknown(
data['partial_path']!,
_partialPathMeta,
),
);
}
if (data.containsKey('bytes_downloaded')) {
context.handle(
_bytesDownloadedMeta,
bytesDownloaded.isAcceptableOrUnknown(
data['bytes_downloaded']!,
_bytesDownloadedMeta,
),
);
}
if (data.containsKey('total_bytes')) {
context.handle(
_totalBytesMeta,
totalBytes.isAcceptableOrUnknown(data['total_bytes']!, _totalBytesMeta),
);
}
if (data.containsKey('status')) {
context.handle(
_statusMeta,
status.isAcceptableOrUnknown(data['status']!, _statusMeta),
);
}
if (data.containsKey('error')) {
context.handle(
_errorMeta,
error.isAcceptableOrUnknown(data['error']!, _errorMeta),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {videoId};
@override
OfflineEntry map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return OfflineEntry(
videoId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}video_id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
localPath: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}local_path'],
)!,
partialPath: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}partial_path'],
),
bytesDownloaded: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}bytes_downloaded'],
)!,
totalBytes: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}total_bytes'],
),
status: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}status'],
)!,
error: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}error'],
),
updatedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$OfflineEntriesTable createAlias(String alias) {
return $OfflineEntriesTable(attachedDatabase, alias);
}
}
class OfflineEntry extends DataClass implements Insertable<OfflineEntry> {
final String videoId;
final String title;
final String localPath;
final String? partialPath;
final int bytesDownloaded;
final int? totalBytes;
final String status;
final String? error;
final DateTime updatedAt;
const OfflineEntry({
required this.videoId,
required this.title,
required this.localPath,
this.partialPath,
required this.bytesDownloaded,
this.totalBytes,
required this.status,
this.error,
required this.updatedAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['video_id'] = Variable<String>(videoId);
map['title'] = Variable<String>(title);
map['local_path'] = Variable<String>(localPath);
if (!nullToAbsent || partialPath != null) {
map['partial_path'] = Variable<String>(partialPath);
}
map['bytes_downloaded'] = Variable<int>(bytesDownloaded);
if (!nullToAbsent || totalBytes != null) {
map['total_bytes'] = Variable<int>(totalBytes);
}
map['status'] = Variable<String>(status);
if (!nullToAbsent || error != null) {
map['error'] = Variable<String>(error);
}
map['updated_at'] = Variable<DateTime>(updatedAt);
return map;
}
OfflineEntriesCompanion toCompanion(bool nullToAbsent) {
return OfflineEntriesCompanion(
videoId: Value(videoId),
title: Value(title),
localPath: Value(localPath),
partialPath: partialPath == null && nullToAbsent
? const Value.absent()
: Value(partialPath),
bytesDownloaded: Value(bytesDownloaded),
totalBytes: totalBytes == null && nullToAbsent
? const Value.absent()
: Value(totalBytes),
status: Value(status),
error: error == null && nullToAbsent
? const Value.absent()
: Value(error),
updatedAt: Value(updatedAt),
);
}
factory OfflineEntry.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return OfflineEntry(
videoId: serializer.fromJson<String>(json['videoId']),
title: serializer.fromJson<String>(json['title']),
localPath: serializer.fromJson<String>(json['localPath']),
partialPath: serializer.fromJson<String?>(json['partialPath']),
bytesDownloaded: serializer.fromJson<int>(json['bytesDownloaded']),
totalBytes: serializer.fromJson<int?>(json['totalBytes']),
status: serializer.fromJson<String>(json['status']),
error: serializer.fromJson<String?>(json['error']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'videoId': serializer.toJson<String>(videoId),
'title': serializer.toJson<String>(title),
'localPath': serializer.toJson<String>(localPath),
'partialPath': serializer.toJson<String?>(partialPath),
'bytesDownloaded': serializer.toJson<int>(bytesDownloaded),
'totalBytes': serializer.toJson<int?>(totalBytes),
'status': serializer.toJson<String>(status),
'error': serializer.toJson<String?>(error),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
OfflineEntry copyWith({
String? videoId,
String? title,
String? localPath,
Value<String?> partialPath = const Value.absent(),
int? bytesDownloaded,
Value<int?> totalBytes = const Value.absent(),
String? status,
Value<String?> error = const Value.absent(),
DateTime? updatedAt,
}) => OfflineEntry(
videoId: videoId ?? this.videoId,
title: title ?? this.title,
localPath: localPath ?? this.localPath,
partialPath: partialPath.present ? partialPath.value : this.partialPath,
bytesDownloaded: bytesDownloaded ?? this.bytesDownloaded,
totalBytes: totalBytes.present ? totalBytes.value : this.totalBytes,
status: status ?? this.status,
error: error.present ? error.value : this.error,
updatedAt: updatedAt ?? this.updatedAt,
);
OfflineEntry copyWithCompanion(OfflineEntriesCompanion data) {
return OfflineEntry(
videoId: data.videoId.present ? data.videoId.value : this.videoId,
title: data.title.present ? data.title.value : this.title,
localPath: data.localPath.present ? data.localPath.value : this.localPath,
partialPath: data.partialPath.present
? data.partialPath.value
: this.partialPath,
bytesDownloaded: data.bytesDownloaded.present
? data.bytesDownloaded.value
: this.bytesDownloaded,
totalBytes: data.totalBytes.present
? data.totalBytes.value
: this.totalBytes,
status: data.status.present ? data.status.value : this.status,
error: data.error.present ? data.error.value : this.error,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('OfflineEntry(')
..write('videoId: $videoId, ')
..write('title: $title, ')
..write('localPath: $localPath, ')
..write('partialPath: $partialPath, ')
..write('bytesDownloaded: $bytesDownloaded, ')
..write('totalBytes: $totalBytes, ')
..write('status: $status, ')
..write('error: $error, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
videoId,
title,
localPath,
partialPath,
bytesDownloaded,
totalBytes,
status,
error,
updatedAt,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is OfflineEntry &&
other.videoId == this.videoId &&
other.title == this.title &&
other.localPath == this.localPath &&
other.partialPath == this.partialPath &&
other.bytesDownloaded == this.bytesDownloaded &&
other.totalBytes == this.totalBytes &&
other.status == this.status &&
other.error == this.error &&
other.updatedAt == this.updatedAt);
}
class OfflineEntriesCompanion extends UpdateCompanion<OfflineEntry> {
final Value<String> videoId;
final Value<String> title;
final Value<String> localPath;
final Value<String?> partialPath;
final Value<int> bytesDownloaded;
final Value<int?> totalBytes;
final Value<String> status;
final Value<String?> error;
final Value<DateTime> updatedAt;
final Value<int> rowid;
const OfflineEntriesCompanion({
this.videoId = const Value.absent(),
this.title = const Value.absent(),
this.localPath = const Value.absent(),
this.partialPath = const Value.absent(),
this.bytesDownloaded = const Value.absent(),
this.totalBytes = const Value.absent(),
this.status = const Value.absent(),
this.error = const Value.absent(),
this.updatedAt = const Value.absent(),
this.rowid = const Value.absent(),
});
OfflineEntriesCompanion.insert({
required String videoId,
required String title,
required String localPath,
this.partialPath = const Value.absent(),
this.bytesDownloaded = const Value.absent(),
this.totalBytes = const Value.absent(),
this.status = const Value.absent(),
this.error = const Value.absent(),
this.updatedAt = const Value.absent(),
this.rowid = const Value.absent(),
}) : videoId = Value(videoId),
title = Value(title),
localPath = Value(localPath);
static Insertable<OfflineEntry> custom({
Expression<String>? videoId,
Expression<String>? title,
Expression<String>? localPath,
Expression<String>? partialPath,
Expression<int>? bytesDownloaded,
Expression<int>? totalBytes,
Expression<String>? status,
Expression<String>? error,
Expression<DateTime>? updatedAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (videoId != null) 'video_id': videoId,
if (title != null) 'title': title,
if (localPath != null) 'local_path': localPath,
if (partialPath != null) 'partial_path': partialPath,
if (bytesDownloaded != null) 'bytes_downloaded': bytesDownloaded,
if (totalBytes != null) 'total_bytes': totalBytes,
if (status != null) 'status': status,
if (error != null) 'error': error,
if (updatedAt != null) 'updated_at': updatedAt,
if (rowid != null) 'rowid': rowid,
});
}
OfflineEntriesCompanion copyWith({
Value<String>? videoId,
Value<String>? title,
Value<String>? localPath,
Value<String?>? partialPath,
Value<int>? bytesDownloaded,
Value<int?>? totalBytes,
Value<String>? status,
Value<String?>? error,
Value<DateTime>? updatedAt,
Value<int>? rowid,
}) {
return OfflineEntriesCompanion(
videoId: videoId ?? this.videoId,
title: title ?? this.title,
localPath: localPath ?? this.localPath,
partialPath: partialPath ?? this.partialPath,
bytesDownloaded: bytesDownloaded ?? this.bytesDownloaded,
totalBytes: totalBytes ?? this.totalBytes,
status: status ?? this.status,
error: error ?? this.error,
updatedAt: updatedAt ?? this.updatedAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (videoId.present) {
map['video_id'] = Variable<String>(videoId.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (localPath.present) {
map['local_path'] = Variable<String>(localPath.value);
}
if (partialPath.present) {
map['partial_path'] = Variable<String>(partialPath.value);
}
if (bytesDownloaded.present) {
map['bytes_downloaded'] = Variable<int>(bytesDownloaded.value);
}
if (totalBytes.present) {
map['total_bytes'] = Variable<int>(totalBytes.value);
}
if (status.present) {
map['status'] = Variable<String>(status.value);
}
if (error.present) {
map['error'] = Variable<String>(error.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<DateTime>(updatedAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('OfflineEntriesCompanion(')
..write('videoId: $videoId, ')
..write('title: $title, ')
..write('localPath: $localPath, ')
..write('partialPath: $partialPath, ')
..write('bytesDownloaded: $bytesDownloaded, ')
..write('totalBytes: $totalBytes, ')
..write('status: $status, ')
..write('error: $error, ')
..write('updatedAt: $updatedAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$OfflineDatabase extends GeneratedDatabase {
_$OfflineDatabase(QueryExecutor e) : super(e);
$OfflineDatabaseManager get managers => $OfflineDatabaseManager(this);
late final $OfflineEntriesTable offlineEntries = $OfflineEntriesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [offlineEntries];
}
typedef $$OfflineEntriesTableCreateCompanionBuilder =
OfflineEntriesCompanion Function({
required String videoId,
required String title,
required String localPath,
Value<String?> partialPath,
Value<int> bytesDownloaded,
Value<int?> totalBytes,
Value<String> status,
Value<String?> error,
Value<DateTime> updatedAt,
Value<int> rowid,
});
typedef $$OfflineEntriesTableUpdateCompanionBuilder =
OfflineEntriesCompanion Function({
Value<String> videoId,
Value<String> title,
Value<String> localPath,
Value<String?> partialPath,
Value<int> bytesDownloaded,
Value<int?> totalBytes,
Value<String> status,
Value<String?> error,
Value<DateTime> updatedAt,
Value<int> rowid,
});
class $$OfflineEntriesTableFilterComposer
extends Composer<_$OfflineDatabase, $OfflineEntriesTable> {
$$OfflineEntriesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get videoId => $composableBuilder(
column: $table.videoId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get title => $composableBuilder(
column: $table.title,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get localPath => $composableBuilder(
column: $table.localPath,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get partialPath => $composableBuilder(
column: $table.partialPath,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get bytesDownloaded => $composableBuilder(
column: $table.bytesDownloaded,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get totalBytes => $composableBuilder(
column: $table.totalBytes,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get status => $composableBuilder(
column: $table.status,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get error => $composableBuilder(
column: $table.error,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnFilters(column),
);
}
class $$OfflineEntriesTableOrderingComposer
extends Composer<_$OfflineDatabase, $OfflineEntriesTable> {
$$OfflineEntriesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get videoId => $composableBuilder(
column: $table.videoId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get title => $composableBuilder(
column: $table.title,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get localPath => $composableBuilder(
column: $table.localPath,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get partialPath => $composableBuilder(
column: $table.partialPath,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get bytesDownloaded => $composableBuilder(
column: $table.bytesDownloaded,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get totalBytes => $composableBuilder(
column: $table.totalBytes,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get status => $composableBuilder(
column: $table.status,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get error => $composableBuilder(
column: $table.error,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$OfflineEntriesTableAnnotationComposer
extends Composer<_$OfflineDatabase, $OfflineEntriesTable> {
$$OfflineEntriesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get videoId =>
$composableBuilder(column: $table.videoId, builder: (column) => column);
GeneratedColumn<String> get title =>
$composableBuilder(column: $table.title, builder: (column) => column);
GeneratedColumn<String> get localPath =>
$composableBuilder(column: $table.localPath, builder: (column) => column);
GeneratedColumn<String> get partialPath => $composableBuilder(
column: $table.partialPath,
builder: (column) => column,
);
GeneratedColumn<int> get bytesDownloaded => $composableBuilder(
column: $table.bytesDownloaded,
builder: (column) => column,
);
GeneratedColumn<int> get totalBytes => $composableBuilder(
column: $table.totalBytes,
builder: (column) => column,
);
GeneratedColumn<String> get status =>
$composableBuilder(column: $table.status, builder: (column) => column);
GeneratedColumn<String> get error =>
$composableBuilder(column: $table.error, builder: (column) => column);
GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$OfflineEntriesTableTableManager
extends
RootTableManager<
_$OfflineDatabase,
$OfflineEntriesTable,
OfflineEntry,
$$OfflineEntriesTableFilterComposer,
$$OfflineEntriesTableOrderingComposer,
$$OfflineEntriesTableAnnotationComposer,
$$OfflineEntriesTableCreateCompanionBuilder,
$$OfflineEntriesTableUpdateCompanionBuilder,
(
OfflineEntry,
BaseReferences<
_$OfflineDatabase,
$OfflineEntriesTable,
OfflineEntry
>,
),
OfflineEntry,
PrefetchHooks Function()
> {
$$OfflineEntriesTableTableManager(
_$OfflineDatabase db,
$OfflineEntriesTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$OfflineEntriesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$OfflineEntriesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$OfflineEntriesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> videoId = const Value.absent(),
Value<String> title = const Value.absent(),
Value<String> localPath = const Value.absent(),
Value<String?> partialPath = const Value.absent(),
Value<int> bytesDownloaded = const Value.absent(),
Value<int?> totalBytes = const Value.absent(),
Value<String> status = const Value.absent(),
Value<String?> error = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => OfflineEntriesCompanion(
videoId: videoId,
title: title,
localPath: localPath,
partialPath: partialPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes,
status: status,
error: error,
updatedAt: updatedAt,
rowid: rowid,
),
createCompanionCallback:
({
required String videoId,
required String title,
required String localPath,
Value<String?> partialPath = const Value.absent(),
Value<int> bytesDownloaded = const Value.absent(),
Value<int?> totalBytes = const Value.absent(),
Value<String> status = const Value.absent(),
Value<String?> error = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => OfflineEntriesCompanion.insert(
videoId: videoId,
title: title,
localPath: localPath,
partialPath: partialPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes,
status: status,
error: error,
updatedAt: updatedAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$OfflineEntriesTableProcessedTableManager =
ProcessedTableManager<
_$OfflineDatabase,
$OfflineEntriesTable,
OfflineEntry,
$$OfflineEntriesTableFilterComposer,
$$OfflineEntriesTableOrderingComposer,
$$OfflineEntriesTableAnnotationComposer,
$$OfflineEntriesTableCreateCompanionBuilder,
$$OfflineEntriesTableUpdateCompanionBuilder,
(
OfflineEntry,
BaseReferences<_$OfflineDatabase, $OfflineEntriesTable, OfflineEntry>,
),
OfflineEntry,
PrefetchHooks Function()
>;
class $OfflineDatabaseManager {
final _$OfflineDatabase _db;
$OfflineDatabaseManager(this._db);
$$OfflineEntriesTableTableManager get offlineEntries =>
$$OfflineEntriesTableTableManager(_db, _db.offlineEntries);
}
+356
View File
@@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state.dart';
import '../theme.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _AppMark(size: 58),
const SizedBox(height: 18),
Text('ImageFind', style: context.text.titleLarge),
const SizedBox(height: 18),
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4),
),
],
),
),
),
);
}
class ConnectScreen extends ConsumerStatefulWidget {
const ConnectScreen({super.key});
@override
ConsumerState<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends ConsumerState<ConnectScreen> {
final _controller = TextEditingController(
text: 'http://imagefind.local:8765',
);
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = ref.watch(sessionProvider);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(
alignment: Alignment.centerLeft,
child: _AppMark(size: 52),
),
const SizedBox(height: 32),
Text('连接你的媒体库', style: context.text.headlineMedium),
const SizedBox(height: 9),
Text(
'输入 ImageFind 的直连地址。你的密码和媒体都不会离开自己的服务。',
style: context.text.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: 28),
TextFormField(
controller: _controller,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
decoration: const InputDecoration(
labelText: '服务器地址',
hintText: 'http://192.168.1.10:8765',
prefixIcon: Icon(Icons.dns_outlined),
),
validator: (value) =>
value == null || value.trim().isEmpty
? '请输入服务器地址'
: null,
onFieldSubmitted: (_) => _connect(),
),
if (session.error != null) ...[
const SizedBox(height: 12),
_InlineError(message: session.error!),
],
const SizedBox(height: 18),
FilledButton(
onPressed: session.busy ? null : _connect,
child: session.busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('检查并继续'),
),
const SizedBox(height: 22),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
size: 20,
color: context.colors.primary,
),
const SizedBox(width: 9),
Expanded(
child: Text(
'支持局域网 HTTP;公网地址必须使用有效的 HTTPS 证书。',
style: context.text.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
],
),
),
),
),
),
),
);
}
Future<void> _connect() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
await ref.read(sessionProvider.notifier).connect(_controller.text);
}
}
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _password = TextEditingController();
final _confirm = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _remember = true;
bool _obscure = true;
@override
void dispose() {
_password.dispose();
_confirm.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = ref.watch(sessionProvider);
final setup = session.stage == SessionStage.setup;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
onPressed: () =>
ref.read(sessionProvider.notifier).logout(),
tooltip: '返回服务器设置',
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
const Spacer(),
if (session.status?.version.isNotEmpty == true)
Text(
'ImageFind ${session.status!.version}',
style: context.text.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 46),
Text(
setup ? '创建管理员密码' : '欢迎回来',
style: context.text.headlineMedium,
),
const SizedBox(height: 9),
Text(
setup
? '首次连接需要设置至少 10 个字符的管理员密码。'
: '使用 ImageFind 管理员密码继续。',
style: context.text.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: 26),
TextFormField(
controller: _password,
obscureText: _obscure,
autofocus: true,
textInputAction: setup
? TextInputAction.next
: TextInputAction.done,
decoration: InputDecoration(
labelText: '管理员密码',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: () => setState(() => _obscure = !_obscure),
tooltip: _obscure ? '显示密码' : '隐藏密码',
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => value == null || value.isEmpty
? '请输入密码'
: setup && value.length < 10
? '密码至少需要 10 个字符'
: null,
onFieldSubmitted: setup ? null : (_) => _authenticate(),
),
if (setup) ...[
const SizedBox(height: 12),
TextFormField(
controller: _confirm,
obscureText: _obscure,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: '再次输入密码',
prefixIcon: Icon(Icons.verified_user_outlined),
),
validator: (value) =>
value != _password.text ? '两次输入的密码不一致' : null,
onFieldSubmitted: (_) => _authenticate(),
),
],
const SizedBox(height: 10),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('记住此设备'),
subtitle: const Text('只保存加密会话,不保存密码'),
value: _remember,
onChanged: (value) => setState(() => _remember = value),
),
if (session.error != null)
_InlineError(message: session.error!),
const SizedBox(height: 16),
FilledButton(
onPressed: session.busy ? null : _authenticate,
child: session.busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(setup ? '创建并进入' : '安全登录'),
),
],
),
),
),
),
),
),
);
}
Future<void> _authenticate() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
await ref
.read(sessionProvider.notifier)
.authenticate(_password.text, remember: _remember);
}
}
class _InlineError extends StatelessWidget {
const _InlineError({required this.message});
final String message;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.colors.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
size: 20,
color: context.colors.onErrorContainer,
),
const SizedBox(width: 9),
Expanded(
child: Text(
message,
style: context.text.bodySmall?.copyWith(
color: context.colors.onErrorContainer,
),
),
),
],
),
);
}
class _AppMark extends StatelessWidget {
const _AppMark({required this.size});
final double size;
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(size * .28),
),
alignment: Alignment.center,
child: Text(
'IF',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: size * .28,
letterSpacing: -.4,
),
),
);
}
File diff suppressed because it is too large Load Diff
+756
View File
@@ -0,0 +1,756 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../api.dart';
import '../models.dart';
import '../state.dart';
import '../theme.dart';
import '../transfer_services.dart';
import '../widgets.dart';
class PlayerScreen extends ConsumerStatefulWidget {
const PlayerScreen({
super.key,
required this.videoId,
this.initialVideo,
this.startMs = 0,
});
final String videoId;
final VideoRecord? initialVideo;
final int startMs;
@override
ConsumerState<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends ConsumerState<PlayerScreen>
with WidgetsBindingObserver {
late final Player _player;
late final VideoController _controller;
StreamSubscription<String>? _errorSubscription;
Timer? _progressTimer;
VideoRecord? _video;
bool _loading = true;
bool _fallbackAttempted = false;
bool _favorited = false;
int _tab = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_player = Player();
_controller = VideoController(_player);
_video = widget.initialVideo;
_favorited = _video?.favorited ?? false;
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
}
Future<void> _initialize() async {
final api = ref.read(apiProvider);
if (_video == null) {
try {
final videos = await api.videos();
_video = videos.where((item) => item.id == widget.videoId).firstOrNull;
} catch (_) {}
}
_video ??= VideoRecord(id: widget.videoId, title: '视频详情', durationMs: 0);
_errorSubscription = _player.stream.error.listen((message) {
if (message.isNotEmpty && !_fallbackAttempted) _openHlsFallback();
});
final offline = await ref
.read(offlineDatabaseProvider)
.downloadFor(widget.videoId);
final useOffline =
offline?.status == 'completed' &&
offline != null &&
await File(offline.localPath).exists();
await _player.open(
useOffline
? Media(offline.localPath)
: Media(
api.streamUri(_video!).toString(),
httpHeaders: api.mediaHeaders,
),
play: true,
);
if (widget.startMs > 0)
await _player.seek(Duration(milliseconds: widget.startMs));
_progressTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _saveProgress(),
);
if (mounted) setState(() => _loading = false);
}
Future<void> _openHlsFallback() async {
_fallbackAttempted = true;
final api = ref.read(apiProvider);
try {
final result = await api.postMap(
'videos/${widget.videoId}/preview',
data: {'start_ms': _player.state.position.inMilliseconds},
);
final playlist = api.absoluteUri(result['playlist_url']?.toString());
if (playlist.hasScheme)
await _player.open(
Media(playlist.toString(), httpHeaders: api.mediaHeaders),
play: true,
);
} on ApiException catch (error) {
if (mounted)
showMessage(context, '播放器无法打开此视频:${error.message}', error: true);
}
}
Future<void> _saveProgress({bool completed = false}) async {
if (_video == null) return;
try {
await ref.read(apiProvider).updateVideoState(widget.videoId, {
'progress_ms': _player.state.position.inMilliseconds,
if (completed) 'completed': true,
});
} catch (_) {}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached)
_saveProgress();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_progressTimer?.cancel();
_errorSubscription?.cancel();
_saveProgress();
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final video =
_video ??
widget.initialVideo ??
VideoRecord(id: widget.videoId, title: '视频详情', durationMs: 0);
final player = _PlayerPane(
player: _player,
controller: _controller,
loading: _loading,
onBack: () => Navigator.of(context).maybePop(),
);
final detail = _DetailPane(
video: video,
player: _player,
tab: _tab,
favorited: _favorited,
onTab: (value) => setState(() => _tab = value),
onFavorite: _toggleFavorite,
onMarker: _addMarker,
onOffline: _downloadOffline,
);
return Scaffold(
backgroundColor: context.isDark
? AppColors.darkCanvas
: AppColors.lightCanvas,
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 840) {
return SafeArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 6,
child: ColoredBox(
color: Colors.black,
child: Align(
alignment: Alignment.topCenter,
child: player,
),
),
),
VerticalDivider(
width: 1,
color: Theme.of(context).dividerColor,
),
Expanded(flex: 5, child: detail),
],
),
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: player),
SliverToBoxAdapter(child: detail),
],
);
},
),
);
}
Future<void> _toggleFavorite() async {
final next = !_favorited;
setState(() => _favorited = next);
try {
await ref.read(apiProvider).updateVideoState(widget.videoId, {
'favorited': next,
});
} on ApiException catch (error) {
if (mounted) {
setState(() => _favorited = !next);
showMessage(context, error.message, error: true);
}
}
}
Future<void> _addMarker() async {
try {
await ref
.read(apiProvider)
.addMarker(widget.videoId, _player.state.position.inMilliseconds);
if (mounted) {
showMessage(
context,
'已记住 ${formatDuration(_player.state.position.inMilliseconds)}',
);
setState(() => _tab = 1);
}
} on ApiException catch (error) {
if (mounted) showMessage(context, error.message, error: true);
}
}
Future<void> _downloadOffline() async {
final video = _video;
if (video == null) return;
if (ref.read(offlineDownloadControllerProvider).contains(video.id)) {
showMessage(context, '正在下载,可在传输中心查看进度');
return;
}
showMessage(context, '已开始离线下载');
try {
await ref
.read(offlineDownloadControllerProvider.notifier)
.download(video);
if (mounted) showMessage(context, '已保存到本机离线内容');
} catch (error) {
if (mounted) {
showMessage(
context,
error is ApiException ? error.message : '离线下载失败,请稍后重试。',
error: true,
);
}
}
}
}
class _PlayerPane extends StatelessWidget {
const _PlayerPane({
required this.player,
required this.controller,
required this.loading,
required this.onBack,
});
final Player player;
final VideoController controller;
final bool loading;
final VoidCallback onBack;
@override
Widget build(BuildContext context) => AspectRatio(
aspectRatio: 16 / 9,
child: ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
Video(
controller: controller,
fit: BoxFit.contain,
controls: NoVideoControls,
),
if (loading)
const Center(child: CircularProgressIndicator(color: Colors.white)),
Align(
alignment: Alignment.topCenter,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
IconButton(
onPressed: onBack,
tooltip: '返回',
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
),
const Spacer(),
IconButton(
onPressed: () {},
tooltip: '更多播放选项',
icon: const Icon(
Icons.more_horiz_rounded,
color: Colors.white,
),
),
],
),
),
),
),
Center(
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, snapshot) => IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: .58),
foregroundColor: Colors.white,
minimumSize: const Size(58, 58),
),
onPressed: () =>
snapshot.data == true ? player.pause() : player.play(),
tooltip: snapshot.data == true ? '暂停' : '播放',
icon: Icon(
snapshot.data == true
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 34,
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(8, 20, 8, 4),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: .72),
],
),
),
child: StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final duration = player.state.duration;
final max = duration.inMilliseconds <= 0
? 1.0
: duration.inMilliseconds.toDouble();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Slider(
value: position.inMilliseconds
.clamp(0, max.toInt())
.toDouble(),
max: max,
onChanged: (value) => player.seek(
Duration(milliseconds: value.round()),
),
),
Row(
children: [
Text(
formatDuration(position.inMilliseconds),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const Spacer(),
Text(
formatDuration(duration.inMilliseconds),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
],
);
},
),
),
),
),
],
),
),
);
}
class _DetailPane extends ConsumerWidget {
const _DetailPane({
required this.video,
required this.player,
required this.tab,
required this.favorited,
required this.onTab,
required this.onFavorite,
required this.onMarker,
required this.onOffline,
});
final VideoRecord video;
final Player player;
final int tab;
final bool favorited;
final ValueChanged<int> onTab;
final VoidCallback onFavorite;
final VoidCallback onMarker;
final VoidCallback onOffline;
@override
Widget build(BuildContext context, WidgetRef ref) => Padding(
padding: EdgeInsets.fromLTRB(
MediaQuery.sizeOf(context).width < 600 ? 6 : 22,
17,
MediaQuery.sizeOf(context).width < 600 ? 6 : 22,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
video.title,
style: context.text.titleLarge,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 5,
children: [
if (video.series.isNotEmpty)
_Meta(
text: video.series,
icon: Icons.collections_bookmark_outlined,
),
if (video.sourceName.isNotEmpty)
_Meta(text: video.sourceName, icon: Icons.dns_outlined),
_Meta(
text: video.resolutionLabel,
icon: Icons.high_quality_outlined,
),
_Meta(text: video.durationLabel, icon: Icons.schedule_rounded),
],
),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_PlayerAction(
icon: favorited ? Icons.star_rounded : Icons.star_outline_rounded,
label: '收藏',
active: favorited,
onTap: onFavorite,
),
_PlayerAction(
icon: Icons.bookmark_add_outlined,
label: '记住此刻',
onTap: onMarker,
),
_PlayerAction(
icon: Icons.download_for_offline_outlined,
label: '离线缓存',
onTap: onOffline,
),
_PlayerAction(
icon: Icons.more_horiz_rounded,
label: '更多',
onTap: () => showMessage(context, '更多播放选项'),
),
],
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
decoration: BoxDecoration(
color: context.colors.primaryContainer.withValues(alpha: .55),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
Icons.lock_outline_rounded,
size: 17,
color: context.colors.primary,
),
const SizedBox(width: 7),
Expanded(
child: Text(
'仅保存在你的 ImageFind 服务中',
style: context.text.bodySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
],
),
),
const SizedBox(height: 12),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 0, label: Text('逐字稿')),
ButtonSegment(value: 1, label: Text('收藏时刻')),
ButtonSegment(value: 2, label: Text('详情')),
],
selected: {tab},
onSelectionChanged: (value) => onTab(value.first),
showSelectedIcon: false,
),
const SizedBox(height: 14),
if (tab == 0) _TranscriptTab(videoId: video.id, player: player),
if (tab == 1) _MarkersTab(videoId: video.id, player: player),
if (tab == 2) _InfoTab(video: video),
],
),
);
}
class _Meta extends StatelessWidget {
const _Meta({required this.text, required this.icon});
final String text;
final IconData icon;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: context.colors.onSurfaceVariant),
const SizedBox(width: 4),
Text(
text,
style: context.text.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
);
}
class _PlayerAction extends StatelessWidget {
const _PlayerAction({
required this.icon,
required this.label,
required this.onTap,
this.active = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool active;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: 66,
minHeight: context.minTouch + 16,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: active
? context.colors.primary
: context.colors.onSurfaceVariant,
),
const SizedBox(height: 4),
Text(
label,
style: context.text.bodySmall?.copyWith(
color: active
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
class _TranscriptTab extends ConsumerWidget {
const _TranscriptTab({required this.videoId, required this.player});
final String videoId;
final Player player;
@override
Widget build(BuildContext context, WidgetRef ref) =>
FutureBuilder<List<TranscriptLine>>(
future: ref.read(apiProvider).transcript(videoId),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done)
return const Center(
child: Padding(
padding: EdgeInsets.all(28),
child: CircularProgressIndicator(),
),
);
if (snapshot.hasError)
return EmptyState(
icon: Icons.subtitles_off_outlined,
title: '逐字稿暂不可用',
message: '${snapshot.error}',
);
final lines = snapshot.data ?? const [];
if (lines.isEmpty)
return const EmptyState(
icon: Icons.subtitles_outlined,
title: '还没有逐字稿',
message: '语音索引完成后会显示在这里。',
);
return GroupSurface(
children: lines
.map(
(line) => ListTile(
minTileHeight: context.minTouch,
leading: Text(
formatDuration(line.startMs),
style: context.text.bodySmall?.copyWith(
color: context.colors.primary,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
title: Text(line.text, style: context.text.bodyMedium),
onTap: () =>
player.seek(Duration(milliseconds: line.startMs)),
),
)
.toList(),
);
},
);
}
class _MarkersTab extends ConsumerStatefulWidget {
const _MarkersTab({required this.videoId, required this.player});
final String videoId;
final Player player;
@override
ConsumerState<_MarkersTab> createState() => _MarkersTabState();
}
class _MarkersTabState extends ConsumerState<_MarkersTab> {
late Future<List<MarkerRecord>> future = ref
.read(apiProvider)
.markers(widget.videoId);
@override
Widget build(BuildContext context) => FutureBuilder<List<MarkerRecord>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done)
return const Center(
child: Padding(
padding: EdgeInsets.all(28),
child: CircularProgressIndicator(),
),
);
if (snapshot.hasError)
return EmptyState(
icon: Icons.bookmark_border_rounded,
title: '无法载入收藏时刻',
message: '${snapshot.error}',
);
final items = snapshot.data ?? const [];
if (items.isEmpty)
return const EmptyState(
icon: Icons.bookmark_add_outlined,
title: '还没有收藏时刻',
message: '播放到想记住的位置,再点击“记住此刻”。',
);
return GroupSurface(
children: items
.map(
(item) => ListTile(
minTileHeight: context.minTouch,
title: Text(item.title),
subtitle: Text(
formatDuration(item.positionMs),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
trailing: IconButton(
onPressed: () async {
await ref
.read(apiProvider)
.deleteMarker(widget.videoId, item.id);
setState(
() => future = ref
.read(apiProvider)
.markers(widget.videoId),
);
},
tooltip: '删除',
icon: const Icon(Icons.delete_outline_rounded),
),
onTap: () =>
widget.player.seek(Duration(milliseconds: item.positionMs)),
),
)
.toList(),
);
},
);
}
class _InfoTab extends StatelessWidget {
const _InfoTab({required this.video});
final VideoRecord video;
@override
Widget build(BuildContext context) => GroupSurface(
children: [
AppListTile(
icon: Icons.title_rounded,
title: '标题',
subtitle: video.title,
onTap: () => showMessage(context, '编辑视频标题'),
),
AppListTile(
icon: Icons.collections_bookmark_outlined,
title: '系列',
subtitle: video.series.isEmpty ? '未整理' : video.series,
onTap: () => showMessage(context, '编辑视频系列'),
),
AppListTile(
icon: Icons.dns_outlined,
title: '来源',
subtitle:
'${video.sourceName.isEmpty ? '未知来源' : video.sourceName} · ${video.width}×${video.height}',
),
AppListTile(
icon: Icons.tag_outlined,
title: '标签',
subtitle: video.tags.isEmpty ? '暂无标签' : video.tags.join(' · '),
onTap: () => showMessage(context, '编辑视频标签'),
),
],
);
}
+267
View File
@@ -0,0 +1,267 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../theme.dart';
import '../api.dart';
import '../state.dart';
import '../transfer_services.dart';
import '../widgets.dart';
class AppShell extends ConsumerWidget {
const AppShell({super.key, required this.index, required this.child});
final int index;
final Widget child;
static const _paths = ['/home', '/search', '/library', '/profile'];
@override
Widget build(BuildContext context, WidgetRef ref) {
final expanded = MediaQuery.sizeOf(context).width >= 600;
if (expanded) {
return Scaffold(
body: SafeArea(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
labelType: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: const Text(
'IF',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 14),
IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: AppColors.coral,
foregroundColor: Colors.white,
minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
),
onPressed: () => _showAddSheet(context, ref),
tooltip: '添加',
icon: const Icon(Icons.add_rounded),
),
],
),
),
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: Text('首页'),
),
NavigationRailDestination(
icon: Icon(Icons.search_rounded),
label: Text('搜索'),
),
NavigationRailDestination(
icon: Icon(Icons.video_library_outlined),
selectedIcon: Icon(Icons.video_library_rounded),
label: Text('资料库'),
),
NavigationRailDestination(
icon: Icon(Icons.person_outline_rounded),
selectedIcon: Icon(Icons.person_rounded),
label: Text('我的'),
),
],
onDestinationSelected: (value) => context.go(_paths[value]),
),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(child: child),
],
),
),
);
}
final selected = index >= 2 ? index + 1 : index;
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: selected,
onDestinationSelected: (value) {
if (value == 2) {
_showAddSheet(context, ref);
return;
}
context.go(_paths[value > 2 ? value - 1 : value]);
},
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: '首页',
),
const NavigationDestination(
icon: Icon(Icons.search_rounded),
label: '搜索',
),
NavigationDestination(
icon: Container(
width: 50,
height: 38,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
color: AppColors.coral,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.add_rounded, color: Colors.white),
),
label: '添加',
),
const NavigationDestination(
icon: Icon(Icons.video_library_outlined),
selectedIcon: Icon(Icons.video_library_rounded),
label: '资料库',
),
const NavigationDestination(
icon: Icon(Icons.person_outline_rounded),
selectedIcon: Icon(Icons.person_rounded),
label: '我的',
),
],
),
);
}
}
Future<void> _showAddSheet(BuildContext context, WidgetRef ref) async {
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (sheetContext) => Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 22),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('添加到 ImageFind', style: sheetContext.text.titleLarge),
const SizedBox(height: 6),
Text(
'选择视频文件、创建服务器下载,或管理媒体来源。',
style: sheetContext.text.bodyMedium?.copyWith(
color: sheetContext.colors.onSurfaceVariant,
),
),
const SizedBox(height: 18),
GroupSurface(
children: [
AppListTile(
icon: Icons.upload_file_outlined,
title: '从此设备上传',
subtitle: '选择视频后创建可恢复的分块上传',
onTap: () async {
Navigator.pop(sheetContext);
final result = await FilePicker.pickFile(
type: FileType.video,
);
if (result != null && context.mounted) {
try {
final sources = await ref
.read(apiProvider)
.writableSources();
if (!context.mounted) return;
if (sources.isEmpty) {
showMessage(
context,
'没有可写的数据源,请先在服务工作台中配置。',
error: true,
);
context.push('/manage/sources');
return;
}
final sourceId = sources.length == 1
? '${sources.first['id']}'
: await _chooseUploadSource(context, sources);
if (sourceId == null || !context.mounted) return;
unawaited(
ref
.read(deviceUploadProvider.notifier)
.start(result, sourceId)
.catchError((_) {}),
);
showMessage(context, '${result.name} 已开始上传');
context.push('/manage/transfers');
} on ApiException catch (error) {
if (context.mounted) {
showMessage(context, error.message, error: true);
}
}
}
},
),
AppListTile(
icon: Icons.link_rounded,
title: '从链接下载',
subtitle: '让 NAS 在后台下载远程视频',
onTap: () {
Navigator.pop(sheetContext);
context.push('/manage/transfers');
},
),
AppListTile(
icon: Icons.dns_outlined,
title: '管理数据源',
subtitle: '本地目录、WebDAV 与 AList',
onTap: () {
Navigator.pop(sheetContext);
context.push('/manage/sources');
},
),
],
),
],
),
),
);
}
Future<String?> _chooseUploadSource(
BuildContext context,
List<Map<String, dynamic>> sources,
) => showModalBottomSheet<String>(
context: context,
useSafeArea: true,
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(6, 2, 6, 12),
child: Text('上传到', style: context.text.titleLarge),
),
for (final source in sources)
AppListTile(
icon: Icons.dns_outlined,
title: '${source['name'] ?? '数据源'}',
subtitle: '${source['kind'] ?? source['driver'] ?? ''}',
onTap: () => Navigator.pop(context, '${source['id']}'),
),
],
),
),
);
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api.dart';
import 'models.dart';
import 'offline_database.dart';
final secureStorageProvider = Provider<FlutterSecureStorage>(
(ref) => const FlutterSecureStorage(),
);
final apiProvider = Provider<ImageFindApi>(
(ref) => ImageFindApi(ref.watch(secureStorageProvider)),
);
final offlineDatabaseProvider = Provider<OfflineDatabase>((ref) {
final database = OfflineDatabase();
ref.onDispose(database.close);
return database;
});
enum SessionStage { booting, disconnected, login, setup, authenticated }
class SessionState {
const SessionState({
this.stage = SessionStage.booting,
this.status,
this.error,
this.busy = false,
});
final SessionStage stage;
final ServerStatus? status;
final String? error;
final bool busy;
SessionState copyWith({
SessionStage? stage,
ServerStatus? status,
String? error,
bool? busy,
bool clearError = false,
}) => SessionState(
stage: stage ?? this.stage,
status: status ?? this.status,
error: clearError ? null : error ?? this.error,
busy: busy ?? this.busy,
);
}
class SessionController extends StateNotifier<SessionState> {
SessionController(this.api) : super(const SessionState()) {
bootstrap();
}
final ImageFindApi api;
Future<void> bootstrap() async {
final hadSession = await api.restore();
if (hadSession && await api.verifySession()) {
state = const SessionState(stage: SessionStage.authenticated);
} else {
state = const SessionState(stage: SessionStage.disconnected);
}
}
Future<bool> connect(String url) async {
state = state.copyWith(busy: true, clearError: true);
try {
final status = await api.connect(url);
state = SessionState(
stage: status.configured ? SessionStage.login : SessionStage.setup,
status: status,
);
return true;
} on ApiException catch (error) {
state = SessionState(
stage: SessionStage.disconnected,
error: error.message,
);
return false;
}
}
Future<bool> authenticate(String password, {required bool remember}) async {
state = state.copyWith(busy: true, clearError: true);
try {
await api.login(
password,
setup: state.stage == SessionStage.setup,
remember: remember,
);
state = const SessionState(stage: SessionStage.authenticated);
return true;
} on ApiException catch (error) {
state = state.copyWith(busy: false, error: error.message);
return false;
}
}
Future<void> logout() async {
await api.logout();
state = const SessionState(stage: SessionStage.disconnected);
}
}
final sessionProvider = StateNotifierProvider<SessionController, SessionState>(
(ref) => SessionController(ref.watch(apiProvider)),
);
final homeProvider = FutureProvider<HomeFeed>(
(ref) => ref.watch(apiProvider).home(),
);
final videosProvider = FutureProvider<List<VideoRecord>>(
(ref) => ref.watch(apiProvider).videos(),
);
final collectionsProvider = FutureProvider<List<CollectionRecord>>(
(ref) => ref.watch(apiProvider).collections(),
);
final peopleProvider = FutureProvider<List<PersonRecord>>(
(ref) => ref.watch(apiProvider).people(),
);
final eventRevisionProvider = StateProvider<int>((ref) => 0);
final eventSyncProvider = Provider<void>((ref) {
if (ref.watch(sessionProvider).stage != SessionStage.authenticated) return;
var stopped = false;
StreamSubscription<Map<String, dynamic>>? subscription;
Timer? reconnect;
var attempts = 0;
void connect() {
if (stopped) return;
subscription = ref
.read(apiProvider)
.events()
.listen(
(_) {
attempts = 0;
ref.read(eventRevisionProvider.notifier).state++;
ref.invalidate(homeProvider);
ref.invalidate(videosProvider);
ref.invalidate(collectionsProvider);
ref.invalidate(peopleProvider);
},
onError: (_) {
if (stopped) return;
attempts++;
final seconds = (1 << attempts.clamp(0, 5)).clamp(2, 30);
reconnect = Timer(Duration(seconds: seconds), connect);
},
onDone: () {
if (!stopped)
reconnect = Timer(const Duration(seconds: 2), connect);
},
cancelOnError: true,
);
}
connect();
ref.onDispose(() {
stopped = true;
reconnect?.cancel();
subscription?.cancel();
});
});
class ThemeModeController extends StateNotifier<ThemeMode> {
ThemeModeController() : super(ThemeMode.system);
void set(ThemeMode value) => state = value;
}
final themeModeProvider = StateNotifierProvider<ThemeModeController, ThemeMode>(
(ref) => ThemeModeController(),
);
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
abstract final class AppColors {
static const blue = Color(0xFF176FE8);
static const blueDark = Color(0xFF76A5FF);
static const coral = Color(0xFFF06478);
static const lightCanvas = Color(0xFFF7F7F4);
static const darkCanvas = Color(0xFF111318);
static const darkSurface = Color(0xFF191C22);
static const success = Color(0xFF18815A);
static const warning = Color(0xFF9A5D10);
static const danger = Color(0xFFCC3F4D);
}
ThemeData buildTheme(Brightness brightness, TargetPlatform platform) {
final dark = brightness == Brightness.dark;
final scheme =
ColorScheme.fromSeed(
seedColor: dark ? AppColors.blueDark : AppColors.blue,
brightness: brightness,
surface: dark ? AppColors.darkSurface : Colors.white,
).copyWith(
primary: dark ? AppColors.blueDark : AppColors.blue,
error: dark ? const Color(0xFFFF7B86) : AppColors.danger,
surface: dark ? AppColors.darkSurface : Colors.white,
surfaceContainerLowest: dark
? AppColors.darkCanvas
: AppColors.lightCanvas,
);
final isIos =
platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
final base = ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: dark
? AppColors.darkCanvas
: AppColors.lightCanvas,
platform: platform,
splashFactory: isIos ? NoSplash.splashFactory : InkSparkle.splashFactory,
visualDensity: VisualDensity.standard,
);
final text = base.textTheme.copyWith(
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontSize: 25,
height: 1.12,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontSize: 20,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: -0.25,
),
titleMedium: base.textTheme.titleMedium?.copyWith(
fontSize: 16,
height: 1.3,
fontWeight: FontWeight.w600,
),
bodyLarge: base.textTheme.bodyLarge?.copyWith(fontSize: 16, height: 1.5),
bodyMedium: base.textTheme.bodyMedium?.copyWith(fontSize: 14, height: 1.45),
bodySmall: base.textTheme.bodySmall?.copyWith(fontSize: 12, height: 1.4),
labelLarge: base.textTheme.labelLarge?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
),
);
return base.copyWith(
textTheme: text,
cupertinoOverrideTheme: CupertinoThemeData(
brightness: brightness,
primaryColor: scheme.primary,
scaffoldBackgroundColor: base.scaffoldBackgroundColor,
),
dividerColor: dark
? Colors.white.withValues(alpha: 0.09)
: const Color(0x1813171E),
cardTheme: CardThemeData(
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: scheme.surface,
),
appBarTheme: AppBarTheme(
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: isIos,
backgroundColor: base.scaffoldBackgroundColor,
surfaceTintColor: Colors.transparent,
titleTextStyle: text.titleMedium?.copyWith(color: scheme.onSurface),
),
navigationBarTheme: NavigationBarThemeData(
height: 68,
elevation: 0,
backgroundColor: dark ? AppColors.darkSurface : Colors.white,
indicatorColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => text.bodySmall?.copyWith(
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
size: 23,
color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
),
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surface,
indicatorColor: Colors.transparent,
selectedIconTheme: IconThemeData(color: scheme.primary),
selectedLabelTextStyle: text.bodySmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: dark ? const Color(0xFF21252D) : Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: base.dividerColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: Size(48, isIos ? 44 : 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: text.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: Size(48, isIos ? 44 : 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(color: base.dividerColor),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
),
);
}
extension ThemeX on BuildContext {
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get text => Theme.of(this).textTheme;
bool get isDark => Theme.of(this).brightness == Brightness.dark;
bool get isIos => Theme.of(this).platform == TargetPlatform.iOS;
double get minTouch => isIos ? 44 : 48;
}
+360
View File
@@ -0,0 +1,360 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:file_picker/file_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'api.dart';
import 'models.dart';
import 'offline_database.dart';
import 'state.dart';
class DeviceUploadTask {
const DeviceUploadTask({
required this.key,
required this.filename,
this.progress = 0,
this.status = 'queued',
this.uploadId,
this.error,
this.canRetry = false,
});
final String key;
final String filename;
final double progress;
final String status;
final String? uploadId;
final String? error;
final bool canRetry;
DeviceUploadTask copyWith({
double? progress,
String? status,
String? uploadId,
String? error,
bool? canRetry,
}) => DeviceUploadTask(
key: key,
filename: filename,
progress: progress ?? this.progress,
status: status ?? this.status,
uploadId: uploadId ?? this.uploadId,
error: error,
canRetry: canRetry ?? this.canRetry,
);
Map<String, dynamic> toJson() => {
'id': uploadId ?? key,
'type': 'device_upload',
'filename': filename,
'progress': progress,
'status': status,
'error': error,
'can_retry': canRetry,
'local_key': key,
};
}
class DeviceUploadController extends StateNotifier<List<DeviceUploadTask>> {
DeviceUploadController(this._api) : super(const []);
final ImageFindApi _api;
final Map<String, ({String path, int size, File? temporary})> _payloads = {};
Future<void> start(PlatformFile file, String sourceId) async {
var path = file.path;
final size = await file.length();
File? temporary;
if (size <= 0) {
throw const ApiException('无法读取所选视频,请重新选择。');
}
if (path == null || path.isEmpty) {
final cache = await getTemporaryDirectory();
temporary = File(
'${cache.path}${Platform.pathSeparator}upload-${DateTime.now().microsecondsSinceEpoch}-${_safeFileName(file.name)}',
);
final sink = temporary.openWrite();
try {
await for (final chunk in file.readAsByteStream()) {
sink.add(chunk);
}
} finally {
await sink.flush();
await sink.close();
}
path = temporary.path;
}
final key = '${DateTime.now().microsecondsSinceEpoch}-${file.name}';
_payloads[key] = (path: path, size: size, temporary: temporary);
state = [
DeviceUploadTask(key: key, filename: file.name),
...state.where((item) => item.status != 'completed').take(9),
];
try {
_replace(key, (task) => task.copyWith(status: 'receiving'));
final id = await _api.uploadVideoFile(
path: path,
filename: file.name,
sizeBytes: size,
sourceId: sourceId,
onCreated: (id) => _replace(
key,
(task) => task.copyWith(uploadId: id, canRetry: true),
),
onProgress: (sent, total) => _replace(
key,
(task) => task.copyWith(
status: 'receiving',
progress: total <= 0 ? 0 : sent / total,
),
),
);
_replace(
key,
(task) => task.copyWith(
status: 'completed',
progress: 1,
uploadId: id,
canRetry: false,
),
);
} on ApiException catch (error) {
_replace(
key,
(task) => task.copyWith(
status: 'failed',
error: error.message,
canRetry: task.uploadId != null,
),
);
rethrow;
} catch (error) {
_replace(
key,
(task) => task.copyWith(
status: 'failed',
error: '$error',
canRetry: task.uploadId != null,
),
);
rethrow;
} finally {
final current = state.where((task) => task.key == key).firstOrNull;
final completed = state
.where((task) => task.key == key)
.any((task) => task.status == 'completed');
if (completed || current?.canRetry != true) {
if (temporary != null && await temporary.exists()) {
await temporary.delete();
}
_payloads.remove(key);
}
}
}
Future<void> retry(String key) async {
final task = state.where((item) => item.key == key).firstOrNull;
final payload = _payloads[key];
if (task?.uploadId == null || payload == null) {
throw const ApiException('原视频已不可用,请重新选择文件。');
}
_replace(
key,
(item) =>
item.copyWith(status: 'receiving', error: null, canRetry: false),
);
try {
await _api.resumeVideoUpload(
uploadId: task!.uploadId!,
path: payload.path,
sizeBytes: payload.size,
onProgress: (sent, total) => _replace(
key,
(item) => item.copyWith(
progress: total <= 0 ? 0 : sent / total,
status: 'receiving',
),
),
);
_replace(
key,
(item) =>
item.copyWith(status: 'completed', progress: 1, canRetry: false),
);
if (payload.temporary case final temporary?) {
if (await temporary.exists()) await temporary.delete();
}
_payloads.remove(key);
} on ApiException catch (error) {
_replace(
key,
(item) => item.copyWith(
status: 'failed',
error: error.message,
canRetry: true,
),
);
rethrow;
}
}
static String _safeFileName(String input) =>
input.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
void _replace(
String key,
DeviceUploadTask Function(DeviceUploadTask task) update,
) {
state = [for (final task in state) task.key == key ? update(task) : task];
}
}
final deviceUploadProvider =
StateNotifierProvider<DeviceUploadController, List<DeviceUploadTask>>(
(ref) => DeviceUploadController(ref.watch(apiProvider)),
);
class OfflineDownloadController extends StateNotifier<Set<String>> {
OfflineDownloadController(this._api, this._database) : super(const {});
final ImageFindApi _api;
final OfflineDatabase _database;
Future<void> download(VideoRecord video) async {
if (state.contains(video.id)) return;
state = {...state, video.id};
var progressWrite = Future<void>.value();
try {
final root = await getApplicationDocumentsDirectory();
final directory = Directory(
'${root.path}${Platform.pathSeparator}offline',
);
await directory.create(recursive: true);
final extension = _extension(video.downloadUrl ?? video.playbackUrl);
final base = _safeName(video.title).isEmpty
? video.id
: _safeName(video.title);
final suffix = video.id.length <= 8 ? video.id : video.id.substring(0, 8);
final localPath =
'${directory.path}${Platform.pathSeparator}$base-$suffix$extension';
final partialPath = '$localPath.part';
final partial = File(partialPath);
final offset = await partial.exists() ? await partial.length() : 0;
await _database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: video.id,
title: video.title,
localPath: localPath,
partialPath: Value(partialPath),
bytesDownloaded: Value(offset),
status: const Value('downloading'),
error: const Value(null),
updatedAt: Value(DateTime.now()),
),
);
void onProgress(int received, int? total) {
progressWrite = progressWrite.then(
(_) => _database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: video.id,
title: video.title,
localPath: localPath,
partialPath: Value(partialPath),
bytesDownloaded: Value(received),
totalBytes: Value(total),
status: const Value('downloading'),
error: const Value(null),
updatedAt: Value(DateTime.now()),
),
),
);
}
try {
await _api.downloadVideoToFile(
video: video,
partialPath: partialPath,
offset: offset,
onProgress: onProgress,
);
} on ApiException catch (error) {
if (offset <= 0 ||
error.statusCode != HttpStatus.requestedRangeNotSatisfiable) {
rethrow;
}
await partial.writeAsBytes(const []);
await _api.downloadVideoToFile(
video: video,
partialPath: partialPath,
offset: 0,
onProgress: onProgress,
);
}
await progressWrite;
final target = File(localPath);
if (await target.exists()) await target.delete();
await partial.rename(localPath);
final total = await target.length();
await _database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: video.id,
title: video.title,
localPath: localPath,
partialPath: const Value(null),
bytesDownloaded: Value(total),
totalBytes: Value(total),
status: const Value('completed'),
error: const Value(null),
updatedAt: Value(DateTime.now()),
),
);
} catch (error) {
await progressWrite.catchError((_) {});
final existing = await _database.downloadFor(video.id);
await _database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: video.id,
title: video.title,
localPath: existing?.localPath ?? '',
partialPath: Value(existing?.partialPath),
bytesDownloaded: Value(existing?.bytesDownloaded ?? 0),
totalBytes: Value(existing?.totalBytes),
status: const Value('failed'),
error: Value(error is ApiException ? error.message : '$error'),
updatedAt: Value(DateTime.now()),
),
);
rethrow;
} finally {
state = {...state}..remove(video.id);
}
}
static String _safeName(String input) {
final safe = input
.replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1F]'), '_')
.trim()
.replaceAll(RegExp(r'[. ]+$'), '');
return safe.length <= 72 ? safe : safe.substring(0, 72);
}
static String _extension(String? url) {
final path = Uri.tryParse(url ?? '')?.path ?? '';
final dot = path.lastIndexOf('.');
if (dot < 0 || path.length - dot > 7) return '.mp4';
return path.substring(dot).toLowerCase();
}
}
final offlineDownloadControllerProvider =
StateNotifierProvider<OfflineDownloadController, Set<String>>(
(ref) => OfflineDownloadController(
ref.watch(apiProvider),
ref.watch(offlineDatabaseProvider),
),
);
final offlineDownloadsProvider = StreamProvider<List<OfflineEntry>>(
(ref) => ref.watch(offlineDatabaseProvider).watchDownloads(),
);
+585
View File
@@ -0,0 +1,585 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'models.dart';
import 'theme.dart';
abstract final class AppSpacing {
static const phoneGutter = 6.0;
static const tabletGutter = 22.0;
}
class ResponsiveValue extends StatelessWidget {
const ResponsiveValue({
super.key,
required this.compact,
this.medium,
required this.expanded,
});
final Widget compact;
final Widget? medium;
final Widget expanded;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth >= 840
? expanded
: constraints.maxWidth >= 600
? medium ?? expanded
: compact,
);
}
class AppPage extends StatelessWidget {
const AppPage({
super.key,
required this.child,
this.paddingTop = 0,
this.safeBottom = true,
this.scrollable = true,
});
final Widget child;
final double paddingTop;
final bool safeBottom;
final bool scrollable;
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final gutter = width < 600
? AppSpacing.phoneGutter
: AppSpacing.tabletGutter;
final content = Padding(
padding: EdgeInsets.fromLTRB(
gutter,
paddingTop,
gutter,
safeBottom ? 18 : 0,
),
child: child,
);
return SafeArea(
top: false,
bottom: safeBottom,
child: scrollable
? CustomScrollView(slivers: [SliverToBoxAdapter(child: content)])
: content,
);
}
}
class InlinePageHeader extends StatelessWidget implements PreferredSizeWidget {
const InlinePageHeader({
super.key,
required this.title,
this.actions = const [],
this.showBack = true,
});
final String title;
final List<Widget> actions;
final bool showBack;
@override
Size get preferredSize => const Size.fromHeight(52);
@override
Widget build(BuildContext context) => AppBar(
toolbarHeight: 52,
automaticallyImplyLeading: showBack,
leadingWidth: 50,
titleSpacing: showBack ? 0 : 12,
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: actions,
);
}
class SectionHeading extends StatelessWidget {
const SectionHeading({
super.key,
required this.title,
this.subtitle,
this.action,
this.onAction,
});
final String title;
final String? subtitle;
final String? action;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(6, 22, 6, 9),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: context.text.titleLarge),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(
subtitle!,
style: context.text.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
),
if (action != null)
TextButton(onPressed: onAction, child: Text(action!)),
],
),
);
}
class MediaGrid extends StatelessWidget {
const MediaGrid({
super.key,
required this.items,
required this.api,
this.maxItems,
this.onTap,
});
final List<VideoRecord> items;
final ImageFindApi api;
final int? maxItems;
final void Function(VideoRecord video)? onTap;
@override
Widget build(BuildContext context) {
final values = maxItems == null ? items : items.take(maxItems!).toList();
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columns = width >= 1100
? 4
: width >= 720
? 3
: 2;
return GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: values.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 15,
childAspectRatio: columns >= 3 ? 1.42 : 1.31,
),
itemBuilder: (context, index) => MediaCard(
video: values[index],
api: api,
treatment: index.isEven ? 0 : 1,
onTap: () {
if (onTap != null) {
onTap!(values[index]);
} else {
context.push(
'/player/${values[index].id}',
extra: values[index],
);
}
},
),
);
},
);
}
}
class MediaCard extends StatelessWidget {
const MediaCard({
super.key,
required this.video,
required this.api,
required this.treatment,
this.onTap,
});
final VideoRecord video;
final ImageFindApi api;
final int treatment;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: '播放 ${video.title}',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: MediaCover(
api: api,
url: video.thumbnailUrl,
treatment: treatment,
duration: video.durationLabel,
progress: video.durationMs <= 0
? 0
: video.progressMs / video.durationMs,
tags: video.tags.take(2).toList(),
),
),
const SizedBox(height: 7),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
video.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.text.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.36,
),
),
),
],
),
),
);
}
class MediaCover extends StatelessWidget {
const MediaCover({
super.key,
required this.api,
this.url,
this.treatment = 0,
this.duration,
this.progress = 0,
this.tags = const [],
this.borderRadius = 12,
});
final ImageFindApi api;
final String? url;
final int treatment;
final String? duration;
final double progress;
final List<String> tags;
final double borderRadius;
@override
Widget build(BuildContext context) {
final uri = api.thumbnailUri(url);
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: context.isDark
? const Color(0xFF202632)
: const Color(0xFFDDE4EF),
child: uri.hasScheme
? Image.network(
uri.toString(),
headers: api.mediaHeaders,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => CustomPaint(
painter: _CoverPainter(treatment, context.isDark),
),
)
: CustomPaint(
painter: _CoverPainter(treatment, context.isDark),
),
),
if (tags.isNotEmpty)
Positioned(
top: 6,
left: 6,
child: Wrap(
spacing: 4,
children: tags
.map(
(tag) => Container(
constraints: const BoxConstraints(maxWidth: 76),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(5),
),
child: Text(
tag,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
)
.toList(),
),
),
if (duration != null)
Positioned(
right: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(5),
),
child: Text(
duration!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
if (progress > 0)
Align(
alignment: Alignment.bottomLeft,
child: FractionallySizedBox(
widthFactor: progress.clamp(0, 1),
child: Container(height: 3, color: context.colors.primary),
),
),
],
),
);
}
}
class _CoverPainter extends CustomPainter {
const _CoverPainter(this.treatment, this.dark);
final int treatment;
final bool dark;
@override
void paint(Canvas canvas, Size size) {
final base = Paint()
..color = treatment == 0
? (dark ? const Color(0xFF25324A) : const Color(0xFFCBD7EA))
: (dark ? const Color(0xFF3B2C43) : const Color(0xFFE6D6E2));
canvas.drawRect(Offset.zero & size, base);
if (treatment == 0) {
canvas.drawCircle(
Offset(size.width * .78, size.height * .72),
size.height * .34,
Paint()
..color = dark ? const Color(0xFF6081A8) : const Color(0xFF7896BC),
);
final path = Path()
..moveTo(0, size.height)
..lineTo(size.width * .54, size.height * .32)
..lineTo(size.width, size.height)
..close();
canvas.drawPath(
path,
Paint()
..color = dark ? const Color(0xFF172033) : const Color(0xFFA7B8D1),
);
} else {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width * .1,
size.height * .18,
size.width * .42,
size.height * .64,
),
const Radius.circular(18),
),
Paint()
..color = dark ? const Color(0xFF71516B) : const Color(0xFFB47B9A),
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(size.width * .73, size.height * .48),
width: size.width * .43,
height: size.height * .36,
),
Paint()
..color = dark ? const Color(0xFFB87968) : const Color(0xFFD8A189),
);
}
}
@override
bool shouldRepaint(covariant _CoverPainter oldDelegate) =>
oldDelegate.treatment != treatment || oldDelegate.dark != dark;
}
class AsyncPane<T> extends StatelessWidget {
const AsyncPane({
super.key,
required this.value,
required this.data,
this.onRetry,
this.emptyMessage = '这里还没有内容',
});
final AsyncValue<T> value;
final Widget Function(T value) data;
final VoidCallback? onRetry;
final String emptyMessage;
@override
Widget build(BuildContext context) => value.when(
loading: () => const Padding(
padding: EdgeInsets.all(36),
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => EmptyState(
icon: Icons.cloud_off_outlined,
title: '暂时无法载入',
message: '$error',
action: onRetry == null
? null
: FilledButton.tonal(onPressed: onRetry, child: const Text('重试')),
),
data: data,
);
}
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
final IconData icon;
final String title;
final String message;
final Widget? action;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 46),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 42, color: context.colors.primary),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: context.text.titleLarge,
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: context.text.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (action != null) ...[const SizedBox(height: 20), action!],
],
),
),
),
);
}
class GroupSurface extends StatelessWidget {
const GroupSurface({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
children[i],
if (i < children.length - 1)
Divider(
height: 1,
indent: 62,
color: Theme.of(context).dividerColor,
),
],
],
),
);
}
class AppListTile extends StatelessWidget {
const AppListTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
this.iconColor,
});
final IconData icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final Color? iconColor;
@override
Widget build(BuildContext context) => ListTile(
minTileHeight: context.minTouch + 16,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (iconColor ?? context.colors.primary).withValues(alpha: .11),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor ?? context.colors.primary, size: 21),
),
title: Text(
title,
style: context.text.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: subtitle == null
? null
: Text(
subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing:
trailing ??
(onTap == null
? null
: const Icon(Icons.chevron_right_rounded, size: 21)),
onTap: onTap,
);
}
void showMessage(BuildContext context, String message, {bool error = false}) {
final messenger = ScaffoldMessenger.of(context);
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: error ? context.colors.error : null,
behavior: SnackBarBehavior.floating,
),
);
}
+1346
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
name: imagefind_mobile
description: "ImageFind private media library for iOS and Android."
# 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.0.0+1
environment:
sdk: ^3.11.0
# 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
flutter_riverpod: ^2.6.1
go_router: ^17.5.0
dio: ^5.11.0
cookie_jar: ^4.0.9
dio_cookie_manager: ^3.5.0
flutter_secure_storage: 10.0.0
shared_preferences: ^2.5.5
media_kit: ^1.2.6
media_kit_video: ^2.0.1
media_kit_libs_video: ^1.0.7
path_provider: ^2.1.6
file_picker: ^12.0.0
image_picker: ^1.2.3
connectivity_plus: ^7.3.1
drift: ^2.34.3
drift_flutter: ^0.3.1
sqlite3_flutter_libs: ^0.6.0+eol
freezed_annotation: ^3.1.0
json_annotation: ^4.12.0
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.15.1
json_serializable: ^6.14.1
drift_dev: ^2.34.0
mocktail: ^1.0.5
sqlparser: 0.44.5
# 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:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# 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
+43
View File
@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:imagefind_mobile/src/api.dart';
import 'package:imagefind_mobile/src/models.dart';
void main() {
test('resume plan skips received chunks and keeps the short final chunk', () {
final chunks = pendingUploadChunks(
sizeBytes: 10,
chunkSize: 4,
received: {1},
);
expect(chunks.map((item) => item.index), [0, 2]);
expect(chunks.map((item) => item.offset), [0, 8]);
expect(chunks.map((item) => item.length), [4, 2]);
});
test('SSE decoder ignores keep-alives and accepts split packets', () async {
final bytes = Stream<List<int>>.fromIterable([
utf8.encode(': keep-alive\n\ndata: {"type":"upload"'),
utf8.encode(',"id":"42"}\n\n'),
]);
final events = await decodeServerEvents(bytes).toList();
expect(events, [
{'type': 'upload', 'id': '42'},
]);
});
test('video payload keeps the dedicated download endpoint', () {
final video = VideoRecord.fromJson({
'id': 'video-1',
'title': '片段',
'duration_ms': 1000,
'download_url': '/api/v1/videos/video-1/download',
});
expect(video.downloadUrl, '/api/v1/videos/video-1/download');
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:imagefind_mobile/src/offline_database.dart';
void main() {
late OfflineDatabase database;
setUp(() => database = OfflineDatabase(NativeDatabase.memory()));
tearDown(() => database.close());
test(
'offline progress survives an upsert and can become completed',
() async {
await database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: 'v1',
title: '测试视频',
localPath: 'video.mp4',
partialPath: const Value('video.mp4.part'),
bytesDownloaded: const Value(512),
totalBytes: const Value(1024),
status: const Value('downloading'),
),
);
await database.saveDownload(
OfflineEntriesCompanion.insert(
videoId: 'v1',
title: '测试视频',
localPath: 'video.mp4',
partialPath: const Value(null),
bytesDownloaded: const Value(1024),
totalBytes: const Value(1024),
status: const Value('completed'),
),
);
final entry = await database.downloadFor('v1');
expect(entry?.status, 'completed');
expect(entry?.partialPath, null);
expect(entry?.bytesDownloaded, 1024);
},
);
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:imagefind_mobile/src/models.dart';
import 'package:imagefind_mobile/src/screens/core_screens.dart';
import 'package:imagefind_mobile/src/screens/shell.dart';
import 'package:imagefind_mobile/src/theme.dart';
void main() {
test('formats media duration', () {
expect(formatDuration(1845000), '30:45');
expect(formatDuration(3723000), '1:02:03');
});
testWidgets('selected navigation has no bubble', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildTheme(Brightness.light, TargetPlatform.android),
home: Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
label: '首页',
),
NavigationDestination(icon: Icon(Icons.search), label: '搜索'),
],
),
),
),
);
final theme = NavigationBarTheme.of(
tester.element(find.byType(NavigationBar)),
);
expect(theme.indicatorColor, Colors.transparent);
});
testWidgets('phone shell and search fit a 390dp viewport', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: buildTheme(Brightness.light, TargetPlatform.android),
home: const AppShell(index: 1, child: SearchScreen()),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byType(NavigationBar), findsOneWidget);
final field = tester.getRect(find.byType(TextField));
expect(field.left, greaterThanOrEqualTo(6));
expect(field.right, lessThan(340));
final navigation = tester.getRect(find.byType(NavigationBar));
expect(navigation.left, 0);
expect(navigation.right, 390);
});
}