optimize mobile workspace and release automation

This commit is contained in:
ngfchl
2026-07-18 23:56:53 +08:00
parent 9fc69d1832
commit be64bd59c4
18 changed files with 1782 additions and 513 deletions
+212
View File
@@ -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
+26 -3
View File
@@ -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 -1
View File
@@ -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}"
+3 -3
View File
@@ -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
View File
@@ -33,7 +33,7 @@ class GuangyaApp extends ConsumerWidget {
}
return ShadApp(
title: '光鸭云盘',
title: '小黄鸭',
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
+7 -1
View File
@@ -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);
}
+1 -1
View File
@@ -73,7 +73,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
const SizedBox(height: 20),
Text(
'光鸭云盘',
'小黄鸭',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
+523 -201
View File
@@ -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;
+9
View File
@@ -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]),
);
},
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -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 {
+99 -13
View File
@@ -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
View File
@@ -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,
),
],
);
}
}
+51
View File
@@ -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)),
),
),
);
}
+30 -7
View File
@@ -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,
),
),
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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);