From be64bd59c400c83c9912cc016461c4f5767656c0 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 23:56:53 +0800 Subject: [PATCH] optimize mobile workspace and release automation --- .github/workflows/build.yml | 212 ++++++ android/app/build.gradle.kts | 29 +- android/app/src/main/AndroidManifest.xml | 2 +- ios/Runner/Info.plist | 6 +- lib/app/app.dart | 2 +- lib/core/storage/media_library_store.dart | 8 +- lib/pages/login_page.dart | 2 +- lib/pages/media_library_page.dart | 724 ++++++++++++++------ lib/pages/search_results_page.dart | 9 + lib/pages/workspace_page.dart | 798 +++++++++++++++++----- lib/providers/file_provider.dart | 9 +- lib/providers/media_library_provider.dart | 112 ++- lib/widgets/file_list_tile.dart | 286 +++++--- lib/widgets/share_link_dialog.dart | 51 ++ lib/widgets/side_panel.dart | 37 +- macos/Runner/Configs/AppInfo.xcconfig | 2 +- windows/runner/Runner.rc | 4 +- windows/runner/main.cpp | 2 +- 18 files changed, 1782 insertions(+), 513 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 lib/widgets/share_link_dialog.dart diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ba7d7ca --- /dev/null +++ b/.github/workflows/build.yml @@ -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 < "$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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 39079e8..ab06d77 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") + } } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb1f296..30fb472 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - 光鸭云盘 + 小黄鸭 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,7 +22,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - guangya_flutter + 小黄鸭 CFBundlePackageType APPL CFBundleShortVersionString @@ -88,7 +88,7 @@ NSCameraUsageDescription 我们需要使用相机来拍摄照片。 NSLocalNetworkUsageDescription - 光鸭云盘需要访问本地网络来发现和连接设备。 + 小黄鸭需要访问本地网络来发现和连接设备。 NSBonjourServices _googlecast._tcp diff --git a/lib/app/app.dart b/lib/app/app.dart index 6e1649f..803c92f 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -33,7 +33,7 @@ class GuangyaApp extends ConsumerWidget { } return ShadApp( - title: '光鸭云盘', + title: '小黄鸭', debugShowCheckedModeBanner: false, theme: lightTheme, darkTheme: darkTheme, diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index 36bc6c7..e75faa2 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -260,7 +260,13 @@ class MediaLibraryStore { Future 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); } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 446515a..da8ef5b 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -73,7 +73,7 @@ class _LoginPageState extends ConsumerState { ), const SizedBox(height: 20), Text( - '光鸭云盘', + '小黄鸭', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 4d99990..b78c00f 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -115,6 +115,7 @@ class _MediaLibraryPageState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(mediaLibraryProvider); + final compact = MediaQuery.sizeOf(context).width < 720; ref.listen(mediaLibraryProvider, (previous, next) { final message = next.errorMessage ?? next.statusMessage; final previousMessage = previous?.errorMessage ?? previous?.statusMessage; @@ -145,15 +146,20 @@ class _MediaLibraryPageState extends ConsumerState { }); 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 { ); } - 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 { 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 { ], ), ), - _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 { 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 { 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 { } } + Future _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 _syncScrapedDataFromCloud() async { + final notifier = ref.read(mediaLibraryProvider.notifier); + setState(() => _backupBusy = true); + List 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( + 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 _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( - 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 _selectionFor(Map candidate) { final selected = Map.from(candidate); if (_mediaKind != 'auto') selected['media_type'] = _mediaKind; diff --git a/lib/pages/search_results_page.dart b/lib/pages/search_results_page.dart index a8971f7..2503903 100644 --- a/lib/pages/search_results_page.dart +++ b/lib/pages/search_results_page.dart @@ -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 { onCopyFastTransfer: () => notifier.copyFastTransferJSON(file), onDownload: () => notifier.downloadFile(file), + onShare: () => unawaited( + showShareLinkDialog( + context, + createLink: () => notifier.createShare(file), + ), + ), onDelete: () => _deleteFiles([file]), ); }, diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 98ec668..867c0ab 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -21,6 +21,7 @@ import '../widgets/breadcrumb_bar.dart'; import '../widgets/file_list_tile.dart'; import '../widgets/media_player_dialog.dart'; import '../widgets/file_icon.dart'; +import '../widgets/share_link_dialog.dart'; import '../widgets/side_panel.dart'; import '../widgets/sort_menu.dart'; import 'media_library_page.dart'; @@ -242,93 +243,145 @@ class _WorkspacePageState extends ConsumerState { backgroundColor: Colors.transparent, body: OS26Surface( child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(18), - child: Row( - children: [ - _mode == WorkspaceMode.cloud - ? _CloudSidebar( - state: fp, - onSection: (section) => - ref.read(fileProvider.notifier).setSection(section), - onSettings: () => _showSettings(context), - onSignOut: () => - ref.read(authProvider.notifier).signOut(), - onTool: _openTool, - ) - : _MediaSidebar( - onCreate: () => - MediaLibraryPage.showCreateDialog(context, ref), - onTool: _openTool, - ), - const SizedBox(width: 16), - Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 720; + final topBar = _TopBar( + mode: _mode, + compact: compact, + onModeChanged: _changeMode, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + searchOpen: _searchOpen, + onSearch: _submitSearch, + onToggleSearch: _toggleSearch, + onSettings: () => _showSettings(context), + ); + final content = IndexedStack( + index: _mode == WorkspaceMode.cloud ? 0 : 1, + children: [_buildCloudContent(fp), _buildMediaContent()], + ); + if (compact) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), child: Column( children: [ - _TopBar( + topBar, + const SizedBox(height: 8), + Expanded(child: content), + const SizedBox(height: 8), + _MobileWorkspaceNavigation( mode: _mode, - onModeChanged: (mode) { - if (_mode == mode) return; - setState(() { - _mode = mode; - _searchOpen = false; - _searchController.clear(); - }); - if (mode == WorkspaceMode.media) { - ref.read(mediaLibraryProvider.notifier).api = ref - .read(authProvider.notifier) - .api; - ref.read(mediaLibraryProvider.notifier).load(); - } - }, - searchController: _searchController, - searchFocusNode: _searchFocusNode, - searchOpen: _searchOpen, - onSearch: (value) { - final query = value.trim(); - if (query.isEmpty) return; - if (_mode == WorkspaceMode.media) { - setState(() => _mediaSearchQuery = query); - ref - .read(mediaLibraryProvider.notifier) - .setSearchQuery(query); - } else { - setState(() => _fileSearchQuery = query); - } - }, - onToggleSearch: () { - setState(() => _searchOpen = !_searchOpen); - if (!_searchOpen) { - _searchController.clear(); - } else { - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - } - }, - onSettings: () => _showSettings(context), - ), - const SizedBox(height: 14), - Expanded( - child: IndexedStack( - index: _mode == WorkspaceMode.cloud ? 0 : 1, - children: [ - _buildCloudContent(fp), - _buildMediaContent(), - ], - ), + onModeChanged: _changeMode, + onMore: () => _showMobileMenu(context), ), ], ), + ); + } + return Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + _mode == WorkspaceMode.cloud + ? _CloudSidebar( + state: fp, + onSection: (section) => ref + .read(fileProvider.notifier) + .setSection(section), + onSettings: () => _showSettings(context), + onSignOut: () => + ref.read(authProvider.notifier).signOut(), + onTool: _openTool, + ) + : _MediaSidebar( + onCreate: () => + MediaLibraryPage.showCreateDialog(context, ref), + onTool: _openTool, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + topBar, + const SizedBox(height: 14), + Expanded(child: content), + ], + ), + ), + ], ), - ], - ), + ); + }, ), ), ), ); } + void _changeMode(WorkspaceMode mode) { + if (_mode == mode) return; + setState(() { + _mode = mode; + _searchOpen = false; + _searchController.clear(); + }); + if (mode == WorkspaceMode.media) { + ref.read(mediaLibraryProvider.notifier).api = ref + .read(authProvider.notifier) + .api; + ref.read(mediaLibraryProvider.notifier).load(); + } + } + + void _submitSearch(String value) { + final query = value.trim(); + if (query.isEmpty) return; + if (_mode == WorkspaceMode.media) { + setState(() => _mediaSearchQuery = query); + ref.read(mediaLibraryProvider.notifier).setSearchQuery(query); + } else { + setState(() => _fileSearchQuery = query); + } + } + + void _toggleSearch() { + setState(() => _searchOpen = !_searchOpen); + if (!_searchOpen) { + _searchController.clear(); + return; + } + WidgetsBinding.instance.addPostFrameCallback( + (_) => _searchFocusNode.requestFocus(), + ); + } + + void _showMobileMenu(BuildContext context) { + showShadSheet( + context: context, + side: ShadSheetSide.bottom, + builder: (context) => _MobileWorkspaceMenu( + mode: _mode, + onSection: (section) { + Navigator.of(context).pop(); + ref.read(fileProvider.notifier).setSection(section); + }, + onTool: (tool) { + Navigator.of(context).pop(); + _openTool(tool); + }, + onCreateLibrary: () { + Navigator.of(context).pop(); + MediaLibraryPage.showCreateDialog(this.context, ref); + }, + onSettings: () { + Navigator.of(context).pop(); + _showSettings(this.context); + }, + ), + ); + } + void _showSettings(BuildContext context) { showDialog(context: context, builder: (_) => const SettingsDialog()); } @@ -416,6 +469,7 @@ class _WorkspacePageState extends ConsumerState { class _TopBar extends StatelessWidget { final WorkspaceMode mode; + final bool compact; final ValueChanged onModeChanged; final TextEditingController searchController; final FocusNode searchFocusNode; @@ -426,6 +480,7 @@ class _TopBar extends StatelessWidget { const _TopBar({ required this.mode, + required this.compact, required this.onModeChanged, required this.searchController, required this.searchFocusNode, @@ -439,6 +494,92 @@ class _TopBar extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final cs = theme.colorScheme; + if (compact) { + return SizedBox( + height: 46, + child: Row( + children: [ + OS26Glass( + radius: 12, + opacity: 0.42, + padding: const EdgeInsets.all(3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SegmentButton( + icon: Icons.folder_rounded, + label: '光鸭云盘', + compact: true, + selected: mode == WorkspaceMode.cloud, + onTap: () => onModeChanged(WorkspaceMode.cloud), + ), + _SegmentButton( + icon: Icons.movie_rounded, + label: '光鸭影视', + compact: true, + selected: mode == WorkspaceMode.media, + onTap: () => onModeChanged(WorkspaceMode.media), + ), + _TopBarIconButton( + tooltip: '设置', + icon: Icons.settings_rounded, + onTap: onSettings, + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: OS26Glass( + radius: 12, + opacity: 0.52, + padding: searchOpen + ? const EdgeInsets.symmetric(horizontal: 10) + : EdgeInsets.zero, + child: searchOpen + ? Row( + children: [ + Icon( + Icons.search_rounded, + size: 17, + color: cs.mutedForeground, + ), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: searchController, + focusNode: searchFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: mode == WorkspaceMode.cloud + ? '搜索文件' + : '搜索影视资源', + ), + textInputAction: TextInputAction.search, + onSubmitted: onSearch, + ), + ), + _TopBarIconButton( + tooltip: '关闭搜索', + icon: Icons.close_rounded, + onTap: onToggleSearch, + ), + ], + ) + : _TopBarIconButton( + tooltip: mode == WorkspaceMode.cloud + ? '搜索文件' + : '搜索影视资源', + icon: Icons.search_rounded, + onTap: onToggleSearch, + ), + ), + ), + ], + ), + ); + } return SizedBox( height: 42, child: Row( @@ -455,12 +596,14 @@ class _TopBar extends StatelessWidget { _SegmentButton( icon: Icons.folder_rounded, label: '光鸭云盘', + compact: false, selected: mode == WorkspaceMode.cloud, onTap: () => onModeChanged(WorkspaceMode.cloud), ), _SegmentButton( icon: Icons.movie_rounded, label: '光鸭影视', + compact: false, selected: mode == WorkspaceMode.media, onTap: () => onModeChanged(WorkspaceMode.media), ), @@ -544,12 +687,14 @@ class _TopBar extends StatelessWidget { class _SegmentButton extends StatelessWidget { final IconData icon; final String label; + final bool compact; final bool selected; final VoidCallback onTap; const _SegmentButton({ required this.icon, required this.label, + required this.compact, required this.selected, required this.onTap, }); @@ -562,8 +707,8 @@ class _SegmentButton extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 160), - width: 126, - height: 32, + width: compact ? 38 : 126, + height: compact ? 38 : 32, decoration: BoxDecoration( color: selected ? cs.secondary : Colors.transparent, borderRadius: BorderRadius.circular(10), @@ -585,13 +730,138 @@ class _SegmentButton extends StatelessWidget { size: 15, color: selected ? cs.foreground : cs.mutedForeground, ), - const SizedBox(width: 7), + if (!compact) ...[ + const SizedBox(width: 7), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: selected ? cs.foreground : cs.mutedForeground, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _TopBarIconButton extends StatelessWidget { + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + const _TopBarIconButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadTooltip( + builder: (_) => Text(tooltip), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: SizedBox( + width: 38, + height: 38, + child: Icon(icon, size: 18, color: cs.mutedForeground), + ), + ), + ); + } +} + +class _MobileWorkspaceNavigation extends StatelessWidget { + final WorkspaceMode mode; + final ValueChanged onModeChanged; + final VoidCallback onMore; + + const _MobileWorkspaceNavigation({ + required this.mode, + required this.onModeChanged, + required this.onMore, + }); + + @override + Widget build(BuildContext context) { + return OS26Glass( + radius: 14, + opacity: 0.68, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + Expanded( + child: _MobileNavItem( + icon: Icons.folder_rounded, + label: '文件', + selected: mode == WorkspaceMode.cloud, + onTap: () => onModeChanged(WorkspaceMode.cloud), + ), + ), + Expanded( + child: _MobileNavItem( + icon: Icons.movie_rounded, + label: '影视', + selected: mode == WorkspaceMode.media, + onTap: () => onModeChanged(WorkspaceMode.media), + ), + ), + Expanded( + child: _MobileNavItem( + icon: Icons.more_horiz_rounded, + label: '更多', + selected: false, + onTap: onMore, + ), + ), + ], + ), + ); + } +} + +class _MobileNavItem extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + const _MobileNavItem({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 19, + color: selected ? cs.primary : cs.mutedForeground, + ), + const SizedBox(height: 2), Text( label, style: TextStyle( - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w600, - color: selected ? cs.foreground : cs.mutedForeground, + fontSize: 11, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? cs.primary : cs.mutedForeground, ), ), ], @@ -601,6 +871,120 @@ class _SegmentButton extends StatelessWidget { } } +class _MobileWorkspaceMenu extends StatelessWidget { + final WorkspaceMode mode; + final ValueChanged onSection; + final ValueChanged onTool; + final VoidCallback onCreateLibrary; + final VoidCallback onSettings; + + const _MobileWorkspaceMenu({ + required this.mode, + required this.onSection, + required this.onTool, + required this.onCreateLibrary, + required this.onSettings, + }); + + @override + Widget build(BuildContext context) { + final isCloud = mode == WorkspaceMode.cloud; + return ShadSheet( + constraints: const BoxConstraints(maxHeight: 560), + title: Text(isCloud ? '云盘功能' : '影视功能'), + description: Text(isCloud ? '切换文件分类或打开工具' : '媒体库与影视工具'), + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (isCloud) + for (final section in WorkspaceSection.values.where( + (section) => section != WorkspaceSection.mediaLibrary, + )) + _MobileMenuButton( + icon: _mobileSectionIcon(section), + label: section.label, + onTap: () => onSection(section), + ) + else + _MobileMenuButton( + icon: Icons.add_rounded, + label: '新建媒体库', + onTap: onCreateLibrary, + ), + _MobileMenuButton( + icon: isCloud + ? Icons.manage_search_rounded + : Icons.movie_filter_rounded, + label: isCloud ? '文件扫描与清理' : '媒体库管理', + onTap: () => + onTool(isCloud ? WorkspaceTool.scan : WorkspaceTool.tmdb), + ), + if (isCloud) ...[ + _MobileMenuButton( + icon: Icons.text_fields_rounded, + label: '批量重命名', + onTap: () => onTool(WorkspaceTool.rename), + ), + _MobileMenuButton( + icon: Icons.bolt_rounded, + label: '秒传工具', + onTap: () => onTool(WorkspaceTool.fastTransfer), + ), + ], + _MobileMenuButton( + icon: Icons.settings_rounded, + label: '设置', + onTap: onSettings, + ), + ], + ), + ), + ); + } + + IconData _mobileSectionIcon(WorkspaceSection section) => switch (section) { + WorkspaceSection.files => Icons.folder_rounded, + WorkspaceSection.recentViewed => Icons.access_time_rounded, + WorkspaceSection.recentRestored => Icons.history_rounded, + WorkspaceSection.photos => Icons.image_rounded, + WorkspaceSection.videos => Icons.smart_display_rounded, + WorkspaceSection.audio => Icons.music_note_rounded, + WorkspaceSection.documents => Icons.description_rounded, + WorkspaceSection.cloud => Icons.cloud_download_rounded, + WorkspaceSection.shares => Icons.ios_share_rounded, + WorkspaceSection.recycle => Icons.delete_outline_rounded, + WorkspaceSection.mediaLibrary => Icons.movie_filter_rounded, + }; +} + +class _MobileMenuButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _MobileMenuButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 112, + child: ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: onTap, + leading: Icon(icon, size: 16), + child: Text(label, overflow: TextOverflow.ellipsis), + ), + ); + } +} + class _CloudSidebar extends StatelessWidget { final FileState state; final ValueChanged onSection; @@ -989,83 +1373,104 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { @override Widget build(BuildContext context) { final state = widget.state; - return Row( - children: [ - Expanded( - child: OS26Glass( - radius: 18, - opacity: 0.42, - padding: const EdgeInsets.all(14), - child: Column( - children: [ - _CloudToolbar( - state: state, - paneMode: _paneMode, - onPaneModeChanged: (mode) => setState(() => _paneMode = mode), - viewMode: _primaryViewMode, - onViewModeChanged: (mode) => - setState(() => _primaryViewMode = mode), - sidePanelOpen: widget.sidePanelOpen, - onToggleSidePanel: widget.onToggleSidePanel, - ), - const SizedBox(height: 12), - Expanded( - child: state.section == WorkspaceSection.files - ? _paneMode == _PaneLayoutMode.dual - ? Row( - children: [ - Expanded( - child: _PrimaryFilePane( - title: '左侧面板', - state: state, - viewMode: _primaryViewMode, - onViewModeChanged: (mode) => setState( - () => _primaryViewMode = mode, - ), - ), + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 720; + final paneMode = compact ? _PaneLayoutMode.single : _paneMode; + final workspace = OS26Glass( + radius: compact ? 8 : 18, + opacity: 0.42, + padding: EdgeInsets.all(compact ? 8 : 14), + child: Column( + children: [ + _CloudToolbar( + state: state, + compact: compact, + paneMode: paneMode, + onPaneModeChanged: (mode) => setState(() => _paneMode = mode), + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), + sidePanelOpen: widget.sidePanelOpen, + onToggleSidePanel: compact + ? () => _showMobileDetails(context) + : widget.onToggleSidePanel, + ), + SizedBox(height: compact ? 8 : 12), + Expanded( + child: state.section == WorkspaceSection.files + ? paneMode == _PaneLayoutMode.dual + ? Row( + children: [ + Expanded( + child: _PrimaryFilePane( + title: '左侧面板', + state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), ), - const SizedBox(width: 10), - const Expanded(child: _SecondaryFilePane()), - ], - ) - : _PrimaryFilePane( - title: '文件列表', - state: state, - viewMode: _primaryViewMode, - onViewModeChanged: (mode) => - setState(() => _primaryViewMode = mode), - ) - : _PrimaryFilePane( - title: state.section.label, - state: state, - viewMode: _primaryViewMode, - onViewModeChanged: (mode) => - setState(() => _primaryViewMode = mode), - ), + ), + const SizedBox(width: 10), + const Expanded(child: _SecondaryFilePane()), + ], + ) + : _PrimaryFilePane( + title: compact ? '文件' : '文件列表', + state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), + ) + : _PrimaryFilePane( + title: state.section.label, + state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), + ), + ), + ], + ), + ); + if (compact) return workspace; + return Row( + children: [ + Expanded(child: workspace), + if (widget.sidePanelOpen) ...[ + const SizedBox(width: 12), + SizedBox( + width: 280, + child: OS26Glass( + radius: 18, + opacity: 0.48, + padding: EdgeInsets.zero, + child: const SidePanel(), ), - ], - ), - ), - ), - if (widget.sidePanelOpen) ...[ - const SizedBox(width: 12), - SizedBox( - width: 280, - child: OS26Glass( - radius: 18, - opacity: 0.48, - padding: EdgeInsets.zero, - child: const SidePanel(), - ), - ), - ], - ], + ), + ], + ], + ); + }, + ); + } + + void _showMobileDetails(BuildContext context) { + showShadSheet( + context: context, + side: ShadSheetSide.bottom, + builder: (_) => const ShadSheet( + constraints: BoxConstraints(maxHeight: 620), + title: Text('详情'), + child: SizedBox(height: 460, child: SidePanel()), + ), ); } } class _CloudToolbar extends ConsumerWidget { final FileState state; + final bool compact; final _PaneLayoutMode paneMode; final ValueChanged<_PaneLayoutMode> onPaneModeChanged; final _FileViewMode viewMode; @@ -1075,6 +1480,7 @@ class _CloudToolbar extends ConsumerWidget { const _CloudToolbar({ required this.state, + required this.compact, required this.paneMode, required this.onPaneModeChanged, required this.viewMode, @@ -1086,9 +1492,9 @@ class _CloudToolbar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(fileProvider.notifier); - return Row( + final controls = Row( + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(), SortMenu( currentSort: state.serverSort, currentDirection: state.serverSortDirection, @@ -1098,9 +1504,15 @@ class _CloudToolbar extends ConsumerWidget { const SizedBox(width: 8), _ToolbarControlGroup( children: [ - _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), - const _ToolbarGroupDivider(), - _FileViewButtons(value: viewMode, onChanged: onViewModeChanged), + if (!compact) ...[ + _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), + const _ToolbarGroupDivider(), + ], + _FileViewButtons( + value: viewMode, + compact: compact, + onChanged: onViewModeChanged, + ), ], ), const SizedBox(width: 8), @@ -1108,6 +1520,7 @@ class _CloudToolbar extends ConsumerWidget { icon: Icons.upload_rounded, label: '上传', primary: true, + compact: compact, onTap: () => _pickAndUpload(ref), ), const SizedBox(width: 8), @@ -1116,18 +1529,21 @@ class _CloudToolbar extends ConsumerWidget { _ToolbarButton( icon: Icons.create_new_folder_rounded, label: '新建文件夹', + compact: compact, onTap: () => _showCreateFolderDialog(context, ref), grouped: true, ), _ToolbarButton( icon: Icons.refresh_rounded, label: '刷新', + compact: compact, onTap: () => notifier.loadFiles(), grouped: true, ), _ToolbarButton( icon: Icons.more_horiz_rounded, label: sidePanelOpen ? '隐藏详情' : '显示详情', + compact: compact, onTap: onToggleSidePanel, grouped: true, selected: sidePanelOpen, @@ -1136,6 +1552,16 @@ class _CloudToolbar extends ConsumerWidget { ), ], ); + if (compact) { + return SizedBox( + height: 40, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: controls, + ), + ); + } + return Row(children: [const Spacer(), controls]); } Future _pickAndUpload(WidgetRef ref) async { @@ -1202,9 +1628,14 @@ class _ToolbarSegment extends StatelessWidget { class _FileViewButtons extends StatelessWidget { final _FileViewMode value; + final bool compact; final ValueChanged<_FileViewMode> onChanged; - const _FileViewButtons({required this.value, required this.onChanged}); + const _FileViewButtons({ + required this.value, + required this.compact, + required this.onChanged, + }); @override Widget build(BuildContext context) { @@ -1215,6 +1646,7 @@ class _FileViewButtons extends StatelessWidget { icon: Icons.view_list_rounded, label: '列表显示', grouped: true, + compact: compact, selected: value == _FileViewMode.list, onTap: () => onChanged(_FileViewMode.list), ), @@ -1222,6 +1654,7 @@ class _FileViewButtons extends StatelessWidget { icon: Icons.view_column_rounded, label: 'Finder 分栏显示', grouped: true, + compact: compact, selected: value == _FileViewMode.columns, onTap: () => onChanged(_FileViewMode.columns), ), @@ -1229,6 +1662,7 @@ class _FileViewButtons extends StatelessWidget { icon: Icons.grid_view_rounded, label: '网格显示', grouped: true, + compact: compact, selected: value == _FileViewMode.grid, onTap: () => onChanged(_FileViewMode.grid), ), @@ -1244,6 +1678,7 @@ class _ToolbarButton extends StatelessWidget { final bool primary; final bool grouped; final bool selected; + final bool compact; const _ToolbarButton({ required this.icon, @@ -1252,6 +1687,7 @@ class _ToolbarButton extends StatelessWidget { this.primary = false, this.grouped = false, this.selected = false, + this.compact = false, }); @override @@ -1265,9 +1701,13 @@ class _ToolbarButton extends StatelessWidget { borderRadius: BorderRadius.circular(10), onTap: onTap, child: Container( - constraints: BoxConstraints(minWidth: primary ? 72 : 36), - height: 32, - padding: EdgeInsets.symmetric(horizontal: primary ? 10 : 0), + constraints: BoxConstraints( + minWidth: compact ? 40 : (primary ? 72 : 36), + ), + height: compact ? 40 : 32, + padding: EdgeInsets.symmetric( + horizontal: compact ? 0 : (primary ? 10 : 0), + ), decoration: BoxDecoration( color: primary ? cs.primary @@ -1299,7 +1739,7 @@ class _ToolbarButton extends StatelessWidget { ? cs.primary : cs.mutedForeground, ), - if (primary) ...[ + if (primary && !compact) ...[ const SizedBox(width: 6), Text( label, @@ -1451,7 +1891,12 @@ class _PrimaryFilePane extends ConsumerWidget { onCopy: () => notifier.copyToClipboard([file]), onCut: () => notifier.cutToClipboard([file]), onDownload: () => notifier.downloadFile(file), - onShare: () => notifier.createShare(file), + onShare: () => unawaited( + showShareLinkDialog( + context, + createLink: () => notifier.createShare(file), + ), + ), onCopyFastTransfer: () => notifier.copyFastTransferJSON(file), isRecycleItem: state.section == WorkspaceSection.recycle, onDelete: () => state.section == WorkspaceSection.recycle @@ -2251,8 +2696,14 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { .cutToClipboard([file]), onDownload: (file) => ref.read(fileProvider.notifier).downloadFile(file), - onShare: (file) => - ref.read(fileProvider.notifier).createShare(file), + onShare: (file) => unawaited( + showShareLinkDialog( + context, + createLink: () => ref + .read(fileProvider.notifier) + .createShare(file), + ), + ), onOpenFolder: (folder) => _openFolder(index, folder), onOpenFile: (file) => _openCloudFile(context, ref, file), @@ -3217,7 +3668,13 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { }, onDownload: () => ref.read(fileProvider.notifier).downloadFile(file), - onShare: () => ref.read(fileProvider.notifier).createShare(file), + onShare: () => unawaited( + showShareLinkDialog( + context, + createLink: () => + ref.read(fileProvider.notifier).createShare(file), + ), + ), onCopyFastTransfer: () => ref.read(fileProvider.notifier).copyFastTransferJSON(file), onDelete: () => @@ -3402,21 +3859,8 @@ class _PanePagination extends StatelessWidget { ), child: Row( children: [ - Icon( - Icons.insert_drive_file_rounded, - size: 14, - color: cs.mutedForeground, - ), - const SizedBox(width: 5), Text( - '本页文件 $fileCount', - style: TextStyle(fontSize: 11, color: cs.mutedForeground), - ), - const SizedBox(width: 12), - Icon(Icons.folder_rounded, size: 14, color: cs.mutedForeground), - const SizedBox(width: 5), - Text( - '本页文件夹 $folderCount', + '文件 $fileCount,文件夹 $folderCount', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), const SizedBox(width: 14), diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index b846d4d..c468099 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -978,8 +978,8 @@ class FileNotifier extends StateNotifier { } } - Future createShare(CloudFile file) async { - if (_api == null) return; + Future 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 { '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 restoreFiles(List files) async { diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 320a4fe..c573c37 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -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 { 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 { } } + Future 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> 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 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 _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 optimizeLocalStorage() async { if (state.isLoading || state.isScanning) return; state = state.copyWith( diff --git a/lib/widgets/file_list_tile.dart b/lib/widgets/file_list_tile.dart index e4f715c..a79d09f 100644 --- a/lib/widgets/file_list_tile.dart +++ b/lib/widgets/file_list_tile.dart @@ -133,6 +133,7 @@ class _FileListTileState extends State { 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 { 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 { : 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, + ), + ], + ); + } } diff --git a/lib/widgets/share_link_dialog.dart b/lib/widgets/share_link_dialog.dart new file mode 100644 index 0000000..9413153 --- /dev/null +++ b/lib/widgets/share_link_dialog.dart @@ -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 showShareLinkDialog( + BuildContext context, { + required Future Function() createLink, +}) async { + final link = await createLink(); + if (!context.mounted || link == null || link.isEmpty) return; + await showShadDialog( + 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)), + ), + ), + ); +} diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart index 991da54..7ac39dd 100644 --- a/lib/widgets/side_panel.dart +++ b/lib/widgets/side_panel.dart @@ -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 files, WidgetRef ref) { + BuildContext context, + List 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, ), ), diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 4651c20..0982d72 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -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 diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 2cc693f..a9043ed 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -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 diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 4444f51..2ebe23a 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -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);