optimize mobile workspace and release automation
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: Build target
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options: [all, quality, android, ios, macos, windows]
|
||||
push:
|
||||
tags: ['*']
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.41.9'
|
||||
RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || github.sha }}
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Analyze and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter analyze
|
||||
- run: flutter test
|
||||
|
||||
android:
|
||||
name: Android release
|
||||
needs: quality
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'android'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
cache: gradle
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
- name: Restore Android release keystore
|
||||
shell: bash
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$KEYSTORE_BASE64" && test -n "$KEYSTORE_PASSWORD" && test -n "$KEY_PASSWORD" && test -n "$KEY_ALIAS"
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/app/release.jks
|
||||
cat > android/key.properties <<EOF
|
||||
storePassword=$KEYSTORE_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=$KEY_ALIAS
|
||||
storeFile=release.jks
|
||||
EOF
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release --split-per-abi
|
||||
- name: Package Android arm64 APK
|
||||
run: cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk guangya_flutter-${{ env.RELEASE_TAG }}-android-arm64.apk
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-arm64
|
||||
path: guangya_flutter-*-android-arm64.apk
|
||||
- name: Publish Android release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: guangya_flutter-*-android-arm64.apk
|
||||
|
||||
ios:
|
||||
name: iOS release
|
||||
needs: quality
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'ios'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
- name: Install iOS release certificate and profile
|
||||
shell: bash
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
|
||||
EXPORT_OPTIONS_BASE64: ${{ secrets.IOS_EXPORT_OPTIONS_PLIST_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$CERTIFICATE_BASE64" && test -n "$CERTIFICATE_PASSWORD" && test -n "$PROFILE_BASE64" && test -n "$KEYCHAIN_PASSWORD" && test -n "$EXPORT_OPTIONS_BASE64"
|
||||
CERT_PATH="$RUNNER_TEMP/ios-release.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/ios-signing.keychain-db"
|
||||
PROFILE_PATH="$RUNNER_TEMP/Guangya.mobileprovision"
|
||||
printf '%s' "$CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
|
||||
printf '%s' "$PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH"
|
||||
printf '%s' "$EXPORT_OPTIONS_BASE64" | base64 --decode > ios/ExportOptions.plist
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
|
||||
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
- run: flutter pub get
|
||||
- run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
|
||||
- run: cp build/ios/ipa/*.ipa guangya_flutter-${{ env.RELEASE_TAG }}-ios.ipa
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: guangya_flutter-*-ios.ipa
|
||||
- name: Publish iOS release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: guangya_flutter-*-ios.ipa
|
||||
|
||||
macos:
|
||||
name: macOS release
|
||||
needs: quality
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'macos'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
- name: Install macOS Developer ID certificate
|
||||
shell: bash
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$CERTIFICATE_BASE64" && test -n "$CERTIFICATE_PASSWORD" && test -n "$KEYCHAIN_PASSWORD"
|
||||
CERT_PATH="$RUNNER_TEMP/macos-release.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/macos-signing.keychain-db"
|
||||
printf '%s' "$CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
- run: flutter pub get
|
||||
- run: flutter build macos --release
|
||||
- name: Sign and package macOS app
|
||||
shell: bash
|
||||
env:
|
||||
SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$SIGNING_IDENTITY"
|
||||
APP=build/macos/Build/Products/Release/小黄鸭.app
|
||||
test -d "$APP"
|
||||
codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APP"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP" guangya_flutter-${{ env.RELEASE_TAG }}-macos.zip
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-app
|
||||
path: guangya_flutter-*-macos.zip
|
||||
- name: Publish macOS release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: guangya_flutter-*-macos.zip
|
||||
|
||||
windows:
|
||||
name: Windows release
|
||||
needs: quality
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'windows'
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build windows --release
|
||||
- name: Package Windows release
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath guangya_flutter-${{ env.RELEASE_TAG }}-windows-x64.zip
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x64
|
||||
path: guangya_flutter-*-windows-x64.zip
|
||||
- name: Publish Windows release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: guangya_flutter-*-windows-x64.zip
|
||||
@@ -1,3 +1,12 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -30,11 +39,25 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Keep local release runs usable; CI always supplies a release key.
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="光鸭云盘"
|
||||
android:label="小黄鸭"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>光鸭云盘</string>
|
||||
<string>小黄鸭</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -22,7 +22,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>guangya_flutter</string>
|
||||
<string>小黄鸭</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -88,7 +88,7 @@
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>我们需要使用相机来拍摄照片。</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>光鸭云盘需要访问本地网络来发现和连接设备。</string>
|
||||
<string>小黄鸭需要访问本地网络来发现和连接设备。</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class GuangyaApp extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return ShadApp(
|
||||
title: '光鸭云盘',
|
||||
title: '小黄鸭',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
|
||||
@@ -260,7 +260,13 @@ class MediaLibraryStore {
|
||||
|
||||
Future<void> exportBackupTo(String destinationPath) async {
|
||||
final db = await _db;
|
||||
await db.execute('PRAGMA wal_checkpoint(FULL)');
|
||||
try {
|
||||
await db.execute('PRAGMA wal_checkpoint(FULL)');
|
||||
} on DatabaseException catch (error) {
|
||||
// sqflite_darwin may report a successful WAL checkpoint as code 0
|
||||
// "not an error". The checkpoint has already completed in that case.
|
||||
if (!error.toString().contains('not an error')) rethrow;
|
||||
}
|
||||
await File(db.path).copy(destinationPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'光鸭云盘',
|
||||
'小黄鸭',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
+523
-201
@@ -115,6 +115,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(mediaLibraryProvider);
|
||||
final compact = MediaQuery.sizeOf(context).width < 720;
|
||||
ref.listen<MediaLibraryState>(mediaLibraryProvider, (previous, next) {
|
||||
final message = next.errorMessage ?? next.statusMessage;
|
||||
final previousMessage = previous?.errorMessage ?? previous?.statusMessage;
|
||||
@@ -145,15 +146,20 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
});
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 10 : 18,
|
||||
compact ? 10 : 14,
|
||||
compact ? 10 : 18,
|
||||
compact ? 10 : 18,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context, state),
|
||||
const SizedBox(height: 12),
|
||||
_buildHeader(context, state, compact: compact),
|
||||
SizedBox(height: compact ? 8 : 12),
|
||||
_buildToolbar(context, state),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: compact ? 8 : 12),
|
||||
Expanded(
|
||||
child: widget.showLibrarySidebar
|
||||
child: widget.showLibrarySidebar && !compact
|
||||
? Row(
|
||||
children: [
|
||||
_buildLibraryList(context, state),
|
||||
@@ -171,12 +177,20 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, MediaLibraryState state) {
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
MediaLibraryState state, {
|
||||
required bool compact,
|
||||
}) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final stats = state.statistics;
|
||||
return Row(
|
||||
final title = Row(
|
||||
children: [
|
||||
Icon(Icons.movie_filter_rounded, size: 26, color: cs.primary),
|
||||
Icon(
|
||||
Icons.movie_filter_rounded,
|
||||
size: compact ? 22 : 26,
|
||||
color: cs.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -185,7 +199,7 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
Text(
|
||||
widget.searchTitle ?? '光鸭影视',
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontSize: compact ? 18 : 21,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
@@ -198,41 +212,59 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'全部',
|
||||
stats.total.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.all,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.all),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'电影',
|
||||
stats.movies.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.movies,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.movies),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'剧集',
|
||||
stats.series.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.series,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.series),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'合集',
|
||||
stats.collections.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.collections,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.collections),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'待匹配',
|
||||
stats.unmatched.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.unmatched,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.unmatched),
|
||||
),
|
||||
],
|
||||
);
|
||||
final pills = [
|
||||
_statPill(
|
||||
context,
|
||||
'全部',
|
||||
stats.total.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.all,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.all),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'电影',
|
||||
stats.movies.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.movies,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.movies),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'剧集',
|
||||
stats.series.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.series,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.series),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'合集',
|
||||
stats.collections.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.collections,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.collections),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
'待匹配',
|
||||
stats.unmatched.toString(),
|
||||
selected: _wallFilter == _MediaWallFilter.unmatched,
|
||||
onTap: () => _selectWallFilter(_MediaWallFilter.unmatched),
|
||||
),
|
||||
];
|
||||
if (compact) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 0, runSpacing: 6, children: pills),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: title),
|
||||
...pills,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -284,6 +316,134 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final detailWork = _detailWork;
|
||||
final compact = MediaQuery.sizeOf(context).width < 720;
|
||||
if (compact) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (detailWork != null) ...[
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () => setState(() => _detailWork = null),
|
||||
child: const Icon(Icons.arrow_back_rounded, size: 18),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _searchController,
|
||||
placeholder: const Text('搜索影视库或匹配 TMDB…'),
|
||||
leading: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 16,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
onChanged: (value) => ref
|
||||
.read(mediaLibraryProvider.notifier)
|
||||
.setSearchQuery(value),
|
||||
onSubmitted: _searchTMDB,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (detailWork != null) ...[
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _detailSyncing
|
||||
? null
|
||||
: () =>
|
||||
unawaited(_refreshAndRecognizeDetail(detailWork)),
|
||||
leading: const Icon(Icons.auto_awesome_rounded, size: 16),
|
||||
child: const Text('媒体识别'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _manualMatchPreparing
|
||||
? null
|
||||
: () => unawaited(_showManualTMDBMatch(detailWork)),
|
||||
leading: const Icon(Icons.manage_search_rounded, size: 16),
|
||||
child: const Text('手动匹配'),
|
||||
),
|
||||
] else ...[
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () => _showCreateLibraryDialog(context, ref),
|
||||
leading: const Icon(Icons.add_rounded, size: 16),
|
||||
child: const Text('媒体库'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _exportScrapedData,
|
||||
leading: const Icon(Icons.save_alt_rounded, size: 16),
|
||||
child: const Text('导出'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _importScrapedData,
|
||||
leading: const Icon(Icons.upload_file_rounded, size: 16),
|
||||
child: const Text('导入'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _syncScrapedDataToCloud,
|
||||
leading: const Icon(Icons.cloud_upload_rounded, size: 16),
|
||||
child: const Text('上传云盘'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _syncScrapedDataFromCloud,
|
||||
leading: const Icon(Icons.cloud_download_rounded, size: 16),
|
||||
child: const Text('云盘恢复'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: state.selectedLibrary == null || state.isScanning
|
||||
? null
|
||||
: () => ref
|
||||
.read(mediaLibraryProvider.notifier)
|
||||
.rescanSelectedLibrary(),
|
||||
leading: const Icon(Icons.refresh_rounded, size: 16),
|
||||
child: const Text('扫描'),
|
||||
),
|
||||
if (state.isScanning)
|
||||
ShadButton.destructive(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () =>
|
||||
ref.read(mediaLibraryProvider.notifier).cancelScan(),
|
||||
leading: const Icon(Icons.stop_rounded, size: 16),
|
||||
child: const Text('停止'),
|
||||
),
|
||||
ShadButton(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _tmdbApiKey.isEmpty
|
||||
? null
|
||||
: () => _searchTMDB(_searchController.text),
|
||||
leading: const Icon(Icons.travel_explore_rounded, size: 16),
|
||||
child: const Text('匹配'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
if (detailWork != null) ...[
|
||||
@@ -348,6 +508,22 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
child: const Text('导入数据'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _syncScrapedDataToCloud,
|
||||
leading: const Icon(Icons.cloud_upload_rounded, size: 16),
|
||||
child: const Text('同步云盘'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: _backupBusy || state.isScanning
|
||||
? null
|
||||
: _syncScrapedDataFromCloud,
|
||||
leading: const Icon(Icons.cloud_download_rounded, size: 16),
|
||||
child: const Text('云盘恢复'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: state.selectedLibrary == null || state.isScanning
|
||||
? null
|
||||
@@ -953,6 +1129,87 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncScrapedDataToCloud() async {
|
||||
final fileState = ref.read(fileProvider);
|
||||
final parentID = fileState.folderPath.isEmpty
|
||||
? null
|
||||
: fileState.folderPath.last.id;
|
||||
setState(() => _backupBusy = true);
|
||||
try {
|
||||
await ref
|
||||
.read(mediaLibraryProvider.notifier)
|
||||
.exportScrapedDataToCloud(parentID: parentID);
|
||||
} finally {
|
||||
if (mounted) setState(() => _backupBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncScrapedDataFromCloud() async {
|
||||
final notifier = ref.read(mediaLibraryProvider.notifier);
|
||||
setState(() => _backupBusy = true);
|
||||
List<CloudFile> backups;
|
||||
try {
|
||||
backups = await notifier.cloudScrapedBackups();
|
||||
} finally {
|
||||
if (mounted) setState(() => _backupBusy = false);
|
||||
}
|
||||
if (!mounted || backups.isEmpty) {
|
||||
if (mounted && backups.isEmpty) {
|
||||
ShadSonner.maybeOf(context)?.show(
|
||||
const ShadToast(
|
||||
title: Text('云盘恢复'),
|
||||
description: Text('云盘中没有找到 media-library.sqlite3 备份。'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final selected = await showShadDialog<CloudFile>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ShadDialog(
|
||||
title: const Text('从云盘恢复刮削数据'),
|
||||
description: const Text('选择一个 SQLite 备份,恢复会合并到当前本地媒体库。'),
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
child: SizedBox(
|
||||
width: 620,
|
||||
height: 360,
|
||||
child: ListView.separated(
|
||||
itemCount: backups.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, index) {
|
||||
final backup = backups[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.storage_rounded),
|
||||
title: Text(
|
||||
backup.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${backup.formattedSize} · ${backup.modifiedAt}',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => Navigator.of(dialogContext).pop(backup),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() => _backupBusy = true);
|
||||
try {
|
||||
await notifier.importScrapedDataFromCloud(selected);
|
||||
} finally {
|
||||
if (mounted) setState(() => _backupBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchTMDB(String query) async {
|
||||
final text = query.trim();
|
||||
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
|
||||
@@ -2186,112 +2443,127 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> {
|
||||
? _tmdbImageURL(item.posterPath!, size: 'w342')
|
||||
: null;
|
||||
final backdrops = _heroBackdropPaths(item);
|
||||
return SizedBox(
|
||||
height: 340,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (backdrops.isNotEmpty)
|
||||
PageView.builder(
|
||||
controller: _backdropController,
|
||||
itemCount: backdrops.length,
|
||||
onPageChanged: (index) => _backdropIndex = index,
|
||||
itemBuilder: (_, index) => CachedNetworkImage(
|
||||
imageUrl: _tmdbImageURL(backdrops[index], size: 'w1280'),
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||||
path: backdrops[index],
|
||||
size: 'w1280',
|
||||
fallback: ColoredBox(color: cs.muted),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ColoredBox(color: cs.muted),
|
||||
ColoredBox(color: Colors.black.withValues(alpha: 0.58)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 225,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: posterURL == null
|
||||
? _detailPosterFallback(cs, isSeries)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: posterURL,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||||
path: item.posterPath!,
|
||||
size: 'w342',
|
||||
fallback: _detailPosterFallback(cs, isSeries),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 600;
|
||||
return SizedBox(
|
||||
height: compact ? 500 : 340,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (backdrops.isNotEmpty)
|
||||
PageView.builder(
|
||||
controller: _backdropController,
|
||||
itemCount: backdrops.length,
|
||||
onPageChanged: (index) => _backdropIndex = index,
|
||||
itemBuilder: (_, index) => CachedNetworkImage(
|
||||
imageUrl: _tmdbImageURL(backdrops[index], size: 'w1280'),
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||||
path: backdrops[index],
|
||||
size: 'w1280',
|
||||
fallback: ColoredBox(color: cs.muted),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ColoredBox(color: cs.muted),
|
||||
ColoredBox(color: Colors.black.withValues(alpha: 0.58)),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(compact ? 16 : 22),
|
||||
child: Flex(
|
||||
direction: compact ? Axis.vertical : Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: compact
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: compact ? 104 : 150,
|
||||
height: compact ? 156 : 225,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: posterURL == null
|
||||
? _detailPosterFallback(cs, isSeries)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: posterURL,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _tmdbDirectFallback(
|
||||
path: item.posterPath!,
|
||||
size: 'w342',
|
||||
fallback: _detailPosterFallback(
|
||||
cs,
|
||||
isSeries,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: compact ? 0 : 20,
|
||||
height: compact ? 12 : 0,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 23 : 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (item.originalTitle.isNotEmpty &&
|
||||
item.originalTitle != item.title) ...[
|
||||
const SizedBox(height: 3),
|
||||
SelectableText(
|
||||
item.originalTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
if (item.originalTitle.isNotEmpty &&
|
||||
item.originalTitle != item.title) ...[
|
||||
const SizedBox(height: 3),
|
||||
SelectableText(
|
||||
item.originalTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
ShadBadge(child: Text(isSeries ? '剧集' : '电影')),
|
||||
if (item.year.isNotEmpty)
|
||||
ShadBadge.outline(child: Text(item.year)),
|
||||
if (item.hasChineseAudio)
|
||||
const ShadBadge.outline(child: Text('中文音轨')),
|
||||
if (item.hasChineseSubtitle)
|
||||
const ShadBadge.outline(child: Text('中文字幕')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SelectableText(
|
||||
item.overview.isEmpty ? '暂无影视简介。' : item.overview,
|
||||
maxLines: compact ? 3 : 4,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
ShadBadge(child: Text(isSeries ? '剧集' : '电影')),
|
||||
if (item.year.isNotEmpty)
|
||||
ShadBadge.outline(child: Text(item.year)),
|
||||
if (item.hasChineseAudio)
|
||||
const ShadBadge.outline(child: Text('中文音轨')),
|
||||
if (item.hasChineseSubtitle)
|
||||
const ShadBadge.outline(child: Text('中文字幕')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SelectableText(
|
||||
item.overview.isEmpty ? '暂无影视简介。' : item.overview,
|
||||
maxLines: 4,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2891,68 +3163,42 @@ class _ManualTMDBMatchDialogState
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 10,
|
||||
Row(
|
||||
children: [
|
||||
_manualMatchField(
|
||||
label: '类型',
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: _mediaKind,
|
||||
options: const [
|
||||
ShadOption(value: 'auto', child: Text('自动')),
|
||||
ShadOption(value: 'movie', child: Text('电影')),
|
||||
ShadOption(value: 'tv', child: Text('电视剧')),
|
||||
],
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
Text(switch (value) {
|
||||
'movie' => '电影',
|
||||
'tv' => '电视剧',
|
||||
_ => '自动',
|
||||
}),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _mediaKind = value);
|
||||
},
|
||||
),
|
||||
Expanded(child: _mediaKindPills(cs)),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: ShadInput(
|
||||
controller: _yearController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('年份(可选)'),
|
||||
),
|
||||
),
|
||||
_manualMatchField(
|
||||
label: '年份',
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
],
|
||||
),
|
||||
if (_mediaKind == 'tv') ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _yearController,
|
||||
controller: _seasonController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('可选'),
|
||||
placeholder: const Text('季号'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mediaKind == 'tv') ...[
|
||||
_manualMatchField(
|
||||
label: '第几季',
|
||||
child: SizedBox(
|
||||
width: 88,
|
||||
child: ShadInput(
|
||||
controller: _seasonController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
),
|
||||
_manualMatchField(
|
||||
label: '第几集',
|
||||
child: SizedBox(
|
||||
width: 88,
|
||||
child: ShadInput(
|
||||
controller: _episodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _episodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('集号'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _searching
|
||||
@@ -2995,6 +3241,11 @@ class _ManualTMDBMatchDialogState
|
||||
final release =
|
||||
(candidate['release_date'] ?? candidate['first_air_date'] ?? '')
|
||||
.toString();
|
||||
final originalTitle =
|
||||
(candidate['original_title'] ?? candidate['original_name'] ?? '')
|
||||
.toString();
|
||||
final mediaType = candidate['media_type'] == 'tv' ? '电视剧' : '电影';
|
||||
final tmdbID = candidate['id']?.toString() ?? '';
|
||||
final posterPath = candidate['poster_path']?.toString();
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
@@ -3005,8 +3256,8 @@ class _ManualTMDBMatchDialogState
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 64,
|
||||
width: 54,
|
||||
height: 78,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: posterPath == null || posterPath.isEmpty
|
||||
@@ -3052,14 +3303,38 @@ class _ManualTMDBMatchDialogState
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${release.length >= 4 ? release.substring(0, 4) : '未知年份'} · ${candidate['media_type'] == 'tv' ? '剧集' : '电影'}',
|
||||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||||
if (originalTitle.isNotEmpty && originalTitle != title) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
originalTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
ShadBadge.outline(child: Text(mediaType)),
|
||||
ShadBadge.outline(
|
||||
child: Text(
|
||||
release.length >= 4
|
||||
? release.substring(0, 4)
|
||||
: '未知年份',
|
||||
),
|
||||
),
|
||||
if (tmdbID.isNotEmpty)
|
||||
ShadBadge.outline(child: Text('TMDB $tmdbID')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
candidate['overview']?.toString() ?? '',
|
||||
maxLines: 2,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
|
||||
),
|
||||
@@ -3074,6 +3349,53 @@ class _ManualTMDBMatchDialogState
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mediaKindPills(ShadColorScheme cs) {
|
||||
const values = [('auto', '自动'), ('movie', '电影'), ('tv', '电视剧')];
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.muted,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: cs.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final option in values)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => setState(() => _mediaKind = option.$1),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _mediaKind == option.$1 ? cs.background : null,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: _mediaKind == option.$1
|
||||
? Border.all(color: cs.border)
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
option.$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: _mediaKind == option.$1
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: _mediaKind == option.$1
|
||||
? cs.foreground
|
||||
: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _selectionFor(Map<String, dynamic> candidate) {
|
||||
final selected = Map<String, dynamic>.from(candidate);
|
||||
if (_mediaKind != 'auto') selected['media_type'] = _mediaKind;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -9,6 +11,7 @@ import '../providers/auth_provider.dart';
|
||||
import '../providers/file_provider.dart';
|
||||
import '../providers/media_library_provider.dart';
|
||||
import '../widgets/file_list_tile.dart';
|
||||
import '../widgets/share_link_dialog.dart';
|
||||
import '../widgets/media_player_dialog.dart';
|
||||
|
||||
class _SearchSelectAllIntent extends Intent {
|
||||
@@ -332,6 +335,12 @@ class _FileSearchResultsPageState extends ConsumerState<FileSearchResultsPage> {
|
||||
onCopyFastTransfer: () =>
|
||||
notifier.copyFastTransferJSON(file),
|
||||
onDownload: () => notifier.downloadFile(file),
|
||||
onShare: () => unawaited(
|
||||
showShareLinkDialog(
|
||||
context,
|
||||
createLink: () => notifier.createShare(file),
|
||||
),
|
||||
),
|
||||
onDelete: () => _deleteFiles([file]),
|
||||
);
|
||||
},
|
||||
|
||||
+621
-177
File diff suppressed because it is too large
Load Diff
@@ -978,8 +978,8 @@ class FileNotifier extends StateNotifier<FileState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createShare(CloudFile file) async {
|
||||
if (_api == null) return;
|
||||
Future<String?> createShare(CloudFile file) async {
|
||||
if (_api == null) return null;
|
||||
try {
|
||||
state = state.copyWith(statusMessage: '正在创建分享…');
|
||||
final result = await _api!.shareCreate([file.id], title: file.name);
|
||||
@@ -990,14 +990,15 @@ class FileNotifier extends StateNotifier<FileState> {
|
||||
'link',
|
||||
]);
|
||||
if (link != null) {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
state = state.copyWith(statusMessage: '分享链接已复制');
|
||||
state = state.copyWith(statusMessage: '分享链接已生成');
|
||||
return link;
|
||||
} else {
|
||||
state = state.copyWith(statusMessage: '分享已创建');
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(errorMessage: e.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> restoreFiles(List<CloudFile> files) async {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
|
||||
import '../api/guangya_api.dart';
|
||||
@@ -258,19 +259,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
clearStatus: true,
|
||||
);
|
||||
try {
|
||||
final stats = await _store.importBackup(backupPath);
|
||||
final libraries = await _loadLibraries();
|
||||
final selectedID = libraries.isEmpty ? null : libraries.first.id;
|
||||
state = state.copyWith(
|
||||
libraries: libraries,
|
||||
selectedLibraryID: selectedID,
|
||||
clearSelectedLibrary: selectedID == null,
|
||||
items: selectedID == null ? const [] : await _loadItems(selectedID),
|
||||
statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}',
|
||||
);
|
||||
if (selectedID != null) {
|
||||
unawaited(_hydrateMissingArtwork(selectedID, state.items));
|
||||
}
|
||||
await _applyImportedBackup(backupPath);
|
||||
} catch (error) {
|
||||
state = state.copyWith(errorMessage: '导入失败:$error');
|
||||
} finally {
|
||||
@@ -295,6 +284,103 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> exportScrapedDataToCloud({String? parentID}) async {
|
||||
if (_api == null || state.isLoading || state.isScanning) return;
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
clearStatus: true,
|
||||
);
|
||||
Directory? temporaryDirectory;
|
||||
try {
|
||||
temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'guangya-media-',
|
||||
);
|
||||
final backup = File('${temporaryDirectory.path}/media-library.sqlite3');
|
||||
await _store.exportBackupTo(backup.path);
|
||||
await _api!.fileUpload(
|
||||
backup,
|
||||
parentID: parentID,
|
||||
contentType: 'application/vnd.sqlite3',
|
||||
);
|
||||
state = state.copyWith(statusMessage: '刮削数据已同步到云盘');
|
||||
} catch (error) {
|
||||
state = state.copyWith(errorMessage: '同步到云盘失败:$error');
|
||||
} finally {
|
||||
if (temporaryDirectory != null && await temporaryDirectory.exists()) {
|
||||
await temporaryDirectory.delete(recursive: true);
|
||||
}
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CloudFile>> cloudScrapedBackups() async {
|
||||
if (_api == null) return const [];
|
||||
final response = await _api!.searchFiles(
|
||||
'media-library.sqlite3',
|
||||
pageSize: 100,
|
||||
);
|
||||
return _extractFiles(response)
|
||||
.where(
|
||||
(file) =>
|
||||
!file.isDirectory && file.name.toLowerCase().endsWith('.sqlite3'),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> importScrapedDataFromCloud(CloudFile backup) async {
|
||||
if (_api == null || state.isLoading || state.isScanning) return;
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
clearStatus: true,
|
||||
);
|
||||
Directory? temporaryDirectory;
|
||||
try {
|
||||
final download = await _api!.downloadURL(backup.id);
|
||||
final url = _findStringDeep(download, const [
|
||||
'url',
|
||||
'downloadUrl',
|
||||
'download_url',
|
||||
'dlink',
|
||||
]);
|
||||
if (url == null || Uri.tryParse(url)?.hasScheme != true) {
|
||||
throw Exception('云盘未返回有效下载地址');
|
||||
}
|
||||
temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'guangya-media-',
|
||||
);
|
||||
final localBackup = File(
|
||||
'${temporaryDirectory.path}/media-library.sqlite3',
|
||||
);
|
||||
await Dio().download(url, localBackup.path);
|
||||
await _applyImportedBackup(localBackup.path);
|
||||
} catch (error) {
|
||||
state = state.copyWith(errorMessage: '从云盘同步失败:$error');
|
||||
} finally {
|
||||
if (temporaryDirectory != null && await temporaryDirectory.exists()) {
|
||||
await temporaryDirectory.delete(recursive: true);
|
||||
}
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyImportedBackup(String backupPath) async {
|
||||
final stats = await _store.importBackup(backupPath);
|
||||
final libraries = await _loadLibraries();
|
||||
final selectedID = libraries.isEmpty ? null : libraries.first.id;
|
||||
state = state.copyWith(
|
||||
libraries: libraries,
|
||||
selectedLibraryID: selectedID,
|
||||
clearSelectedLibrary: selectedID == null,
|
||||
items: selectedID == null ? const [] : await _loadItems(selectedID),
|
||||
statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}',
|
||||
);
|
||||
if (selectedID != null) {
|
||||
unawaited(_hydrateMissingArtwork(selectedID, state.items));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> optimizeLocalStorage() async {
|
||||
if (state.isLoading || state.isScanning) return;
|
||||
state = state.copyWith(
|
||||
|
||||
+189
-97
@@ -133,6 +133,7 @@ class _FileListTileState extends State<FileListTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final compact = MediaQuery.sizeOf(context).width < 720;
|
||||
return ShadContextMenuRegion(
|
||||
items: [
|
||||
if (widget.isRecycleItem)
|
||||
@@ -209,7 +210,7 @@ class _FileListTileState extends State<FileListTile> {
|
||||
onTap: _isRenaming ? null : widget.onSelect,
|
||||
onDoubleTap: _isRenaming ? null : widget.onOpen,
|
||||
child: Container(
|
||||
height: 62,
|
||||
height: compact ? 74 : 62,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
@@ -217,106 +218,197 @@ class _FileListTileState extends State<FileListTile> {
|
||||
: cs.card,
|
||||
border: Border(bottom: BorderSide(color: cs.border, width: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
LucideIcons.checkCircle,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: FileIcon(file: widget.file),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _isRenaming
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _renameController,
|
||||
focusNode: _renameFocusNode,
|
||||
autofocus: true,
|
||||
enabled: !_isSubmittingRename,
|
||||
onSubmitted: (_) => _confirmRename(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename
|
||||
? null
|
||||
: _confirmRename,
|
||||
child: const Icon(Icons.check_rounded, size: 17),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename
|
||||
? null
|
||||
: _cancelRename,
|
||||
child: const Icon(Icons.close_rounded, size: 17),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.file.directoryContentSummary
|
||||
case final String summary)
|
||||
Text(
|
||||
summary,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: compact
|
||||
? _buildCompactContent(theme)
|
||||
: Row(
|
||||
children: [
|
||||
if (widget.isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
LucideIcons.checkCircle,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
widget.file.formattedSize,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: FileIcon(file: widget.file),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _isRenaming
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _renameController,
|
||||
focusNode: _renameFocusNode,
|
||||
autofocus: true,
|
||||
enabled: !_isSubmittingRename,
|
||||
onSubmitted: (_) => _confirmRename(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename
|
||||
? null
|
||||
: _confirmRename,
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 17,
|
||||
),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename
|
||||
? null
|
||||
: _cancelRename,
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 17,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.file.directoryContentSummary
|
||||
case final String summary)
|
||||
Text(
|
||||
summary,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
widget.file.formattedSize,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
widget.file.modifiedAt,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
widget.file.modifiedAt,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactContent(ShadThemeData theme) {
|
||||
final file = widget.file;
|
||||
final summary = file.directoryContentSummary;
|
||||
final metadata = [
|
||||
if (summary case final String value) value,
|
||||
file.formattedSize,
|
||||
if (file.modifiedAt.isNotEmpty) file.modifiedAt,
|
||||
].join(' · ');
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
LucideIcons.checkCircle,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 38, height: 38, child: FileIcon(file: file)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _isRenaming
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: _renameController,
|
||||
focusNode: _renameFocusNode,
|
||||
autofocus: true,
|
||||
enabled: !_isSubmittingRename,
|
||||
onSubmitted: (_) => _confirmRename(),
|
||||
),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename ? null : _confirmRename,
|
||||
child: const Icon(Icons.check_rounded, size: 17),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isSubmittingRename ? null : _cancelRename,
|
||||
child: const Icon(Icons.close_rounded, size: 17),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
metadata,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_isRenaming && file.isDirectory)
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
Future<void> showShareLinkDialog(
|
||||
BuildContext context, {
|
||||
required Future<String?> Function() createLink,
|
||||
}) async {
|
||||
final link = await createLink();
|
||||
if (!context.mounted || link == null || link.isEmpty) return;
|
||||
await showShadDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ShadDialog(
|
||||
title: const Text('分享链接已生成'),
|
||||
description: const Text('链接有效期与访问权限以云盘分享设置为准。'),
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
},
|
||||
leading: const Icon(Icons.copy_rounded, size: 16),
|
||||
child: const Text('复制'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: () => SharePlus.instance.share(ShareParams(text: link)),
|
||||
leading: const Icon(Icons.ios_share_rounded, size: 16),
|
||||
child: const Text('分享'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
width: 460,
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: ShadTheme.of(dialogContext).colorScheme.muted,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: ShadTheme.of(dialogContext).colorScheme.border,
|
||||
),
|
||||
),
|
||||
child: SelectableText(link, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../providers/file_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../models/cloud_file.dart';
|
||||
import 'share_link_dialog.dart';
|
||||
|
||||
class SidePanel extends ConsumerWidget {
|
||||
const SidePanel({super.key});
|
||||
@@ -25,7 +26,11 @@ class SidePanel extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.info, size: 18, color: theme.colorScheme.foreground),
|
||||
Icon(
|
||||
LucideIcons.info,
|
||||
size: 18,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'详情',
|
||||
@@ -118,7 +123,10 @@ class SidePanel extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildSingleFileDetail(
|
||||
BuildContext context, CloudFile file, WidgetRef ref) {
|
||||
BuildContext context,
|
||||
CloudFile file,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -165,7 +173,10 @@ class SidePanel extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildMultiSelectDetail(
|
||||
BuildContext context, List<CloudFile> files, WidgetRef ref) {
|
||||
BuildContext context,
|
||||
List<CloudFile> files,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -202,7 +213,10 @@ class SidePanel extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(
|
||||
BuildContext context, CloudFile file, WidgetRef ref) {
|
||||
BuildContext context,
|
||||
CloudFile file,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final fp = ref.read(fileProvider.notifier);
|
||||
return Wrap(
|
||||
@@ -238,7 +252,10 @@ class SidePanel extends ConsumerWidget {
|
||||
context,
|
||||
icon: LucideIcons.share2,
|
||||
label: '分享',
|
||||
onTap: () {},
|
||||
onTap: () => showShareLinkDialog(
|
||||
context,
|
||||
createLink: () => fp.createShare(file),
|
||||
),
|
||||
),
|
||||
_actionChip(
|
||||
context,
|
||||
@@ -286,12 +303,18 @@ class SidePanel extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.mutedForeground),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 13, color: theme.colorScheme.foreground),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = guangya_flutter
|
||||
PRODUCT_NAME = 小黄鸭
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangyaFlutter
|
||||
|
||||
@@ -90,12 +90,12 @@ BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.ptools" "\0"
|
||||
VALUE "FileDescription", "guangya_flutter" "\0"
|
||||
VALUE "FileDescription", "小黄鸭" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "guangya_flutter" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.ptools. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "guangya_flutter.exe" "\0"
|
||||
VALUE "ProductName", "guangya_flutter" "\0"
|
||||
VALUE "ProductName", "小黄鸭" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"guangya_flutter", origin, size)) {
|
||||
if (!window.Create(L"小黄鸭", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in New Issue
Block a user