From 80f0db699669086c7d11f1d932f0264c8d013686 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sun, 19 Jul 2026 00:14:00 +0800 Subject: [PATCH] audit responsive workspace and interaction UI --- lib/app/app.dart | 18 +- lib/core/logging/app_logger.dart | 1 + lib/pages/login_page.dart | 76 ++-- lib/pages/search_results_page.dart | 80 ++-- lib/pages/settings_page.dart | 99 ++--- lib/pages/workspace_page.dart | 5 +- lib/pages/workspace_tools_page.dart | 575 +++++++++++++++------------ lib/widgets/app_log_dialog.dart | 6 +- lib/widgets/breadcrumb_bar.dart | 103 +++-- lib/widgets/file_list_tile.dart | 228 ++++++----- lib/widgets/media_player_dialog.dart | 49 ++- 11 files changed, 696 insertions(+), 544 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 803c92f..49f3f67 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -9,17 +9,27 @@ import '../pages/login_page.dart'; import '../pages/workspace_page.dart'; import 'app_theme.dart'; -class GuangyaApp extends ConsumerWidget { +class GuangyaApp extends ConsumerStatefulWidget { const GuangyaApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _GuangyaAppState(); +} + +class _GuangyaAppState extends ConsumerState { + var _sessionInitialized = false; + + @override + Widget build(BuildContext context) { final themeState = ref.watch(themeProvider); final auth = ref.watch(authProvider); - // Auto-load files when signed in - if (auth.isSignedIn) { + if (!auth.isSignedIn) { + _sessionInitialized = false; + } else if (!_sessionInitialized) { + _sessionInitialized = true; WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !ref.read(authProvider).isSignedIn) return; final fp = ref.read(fileProvider.notifier); fp.api = ref.read(authProvider.notifier).api; final media = ref.read(mediaLibraryProvider.notifier); diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index 59e0c3d..ea15e28 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -37,6 +37,7 @@ class AppLogEntry { 'Auth' => '认证', 'Media' => '媒体库', 'CloudIndex' => '全盘索引', + 'Player' => '播放器', 'App' => '应用', 'Flutter' => '界面', 'Dart' => '运行时', diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index da8ef5b..43c6cff 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -27,23 +27,14 @@ class _LoginPageState extends ConsumerState { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final auth = ref.watch(authProvider); + final compact = MediaQuery.sizeOf(context).width < 480; return Scaffold( - body: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - theme.colorScheme.primary.withAlpha(30), - theme.colorScheme.background, - theme.colorScheme.primary.withAlpha(15), - ], - ), - ), + body: ColoredBox( + color: theme.colorScheme.background, child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.all(32), + padding: EdgeInsets.all(compact ? 16 : 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( @@ -93,7 +84,7 @@ class _LoginPageState extends ConsumerState { // Login card ShadCard( child: Padding( - padding: const EdgeInsets.all(24), + padding: EdgeInsets.all(compact ? 18 : 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -297,33 +288,38 @@ class _TabButton extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isActive ? theme.colorScheme.background : Colors.transparent, + return Semantics( + button: true, + selected: isActive, + label: label, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(6), - boxShadow: isActive - ? [ - BoxShadow( - color: Colors.black.withAlpha(20), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - color: isActive - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isActive + ? theme.colorScheme.background + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: isActive + ? Border.all(color: theme.colorScheme.border) + : null, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + color: isActive + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), + ), ), ), ), diff --git a/lib/pages/search_results_page.dart b/lib/pages/search_results_page.dart index 2503903..e8139dc 100644 --- a/lib/pages/search_results_page.dart +++ b/lib/pages/search_results_page.dart @@ -252,10 +252,12 @@ class _FileSearchResultsPageState extends ConsumerState { if (selected.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Row( + child: Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('已选择 ${selected.length} 项'), - const Spacer(), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () => notifier.copyToClipboard(selected), @@ -432,10 +434,12 @@ class _MediaSearchResultsPageState if (selected.isNotEmpty) Padding( padding: const EdgeInsets.all(12), - child: Row( + child: Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('已选择 ${selected.length} 个资源'), - const Spacer(), ShadButton( size: ShadButtonSize.sm, onPressed: _recognizing @@ -638,30 +642,56 @@ class _SearchPageFrame extends StatelessWidget { return Column( children: [ Container( - height: 54, - padding: const EdgeInsets.symmetric(horizontal: 12), + constraints: const BoxConstraints(minHeight: 54), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.border)), ), - child: Row( - children: [ - ShadButton.ghost( - onPressed: onClose, - leading: const Icon(Icons.arrow_back_rounded, size: 16), - child: const Text('返回'), - ), - const SizedBox(width: 10), - Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: cs.foreground, - ), - ), - const SizedBox(width: 8), - ShadBadge(child: Text(query)), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 500; + return Row( + children: [ + ShadTooltip( + builder: (_) => const Text('返回'), + child: ShadButton.ghost( + size: compact + ? ShadButtonSize.sm + : ShadButtonSize.regular, + onPressed: onClose, + leading: const Icon(Icons.arrow_back_rounded, size: 16), + child: compact + ? const SizedBox.shrink() + : const Text('返回'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: cs.foreground, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 120 : 260), + child: ShadBadge( + child: Text( + query, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + }, ), ), Expanded(child: child), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 5da2bab..0eb60ac 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -72,6 +72,7 @@ class _SettingsDialogState extends ConsumerState { final cs = ShadTheme.of(context).colorScheme; final themeState = ref.watch(themeProvider); final mediaState = ref.watch(mediaLibraryProvider); + final compact = MediaQuery.sizeOf(context).width < 760; return ShadDialog( title: Row( @@ -97,10 +98,11 @@ class _SettingsDialogState extends ConsumerState { ), ], child: SizedBox( - width: 720, + width: compact ? MediaQuery.sizeOf(context).width - 32 : 720, child: ShadTabs<_SettingsTab>( value: _activeTab, onChanged: (value) => setState(() => _activeTab = value), + scrollable: compact, tabBarConstraints: const BoxConstraints(maxWidth: 720), contentConstraints: const BoxConstraints(maxWidth: 720), tabs: [ @@ -327,13 +329,16 @@ class _SettingsDialogState extends ConsumerState { ); } - Widget _tabScroll(Widget child) => SizedBox( - height: 360, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 16), - child: child, - ), - ); + Widget _tabScroll(Widget child) { + final availableHeight = MediaQuery.sizeOf(context).height - 270; + return SizedBox( + height: availableHeight.clamp(260.0, 420.0), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: child, + ), + ); + } Widget _textInput( TextEditingController controller, { @@ -356,46 +361,48 @@ class _SettingsDialogState extends ConsumerState { ); Future _saveSettings() async { - StorageManager.set( - StorageKeys.tmdbApiKey, - _tmdbApiKeyController.text.trim(), - ); - StorageManager.set( - StorageKeys.tmdbImageProxy, - _tmdbImageProxyController.text.trim(), - ); - await StorageManager.set( - StorageKeys.httpProxyHost, - _httpProxyHostController.text.trim(), - ); - await StorageManager.set( - StorageKeys.httpProxyPort, - _httpProxyPortController.text.trim(), - ); - await StorageManager.delete(StorageKeys.tmdbProxyHost); - await StorageManager.delete(StorageKeys.tmdbProxyPort); + await Future.wait([ + StorageManager.set( + StorageKeys.tmdbApiKey, + _tmdbApiKeyController.text.trim(), + ), + StorageManager.set( + StorageKeys.tmdbImageProxy, + _tmdbImageProxyController.text.trim(), + ), + StorageManager.set( + StorageKeys.httpProxyHost, + _httpProxyHostController.text.trim(), + ), + StorageManager.set( + StorageKeys.httpProxyPort, + _httpProxyPortController.text.trim(), + ), + StorageManager.delete(StorageKeys.tmdbProxyHost), + StorageManager.delete(StorageKeys.tmdbProxyPort), + StorageManager.set( + StorageKeys.mediaScanConcurrency, + _scanConcurrencyController.text.trim(), + ), + StorageManager.set( + StorageKeys.fastTransferConcurrency, + _transferConcurrencyController.text.trim(), + ), + StorageManager.set( + StorageKeys.fileCacheTTLMinutes, + _cacheTTLController.text.trim(), + ), + StorageManager.set( + StorageKeys.cloudIndexRefreshMinutes, + _cloudIndexRefreshController.text.trim(), + ), + StorageManager.set( + StorageKeys.defaultFilePageSize, + _pageSizeController.text.trim(), + ), + ]); DioClient.updateNetworkProxy(); - StorageManager.set( - StorageKeys.mediaScanConcurrency, - _scanConcurrencyController.text.trim(), - ); - StorageManager.set( - StorageKeys.fastTransferConcurrency, - _transferConcurrencyController.text.trim(), - ); - StorageManager.set( - StorageKeys.fileCacheTTLMinutes, - _cacheTTLController.text.trim(), - ); - await StorageManager.set( - StorageKeys.cloudIndexRefreshMinutes, - _cloudIndexRefreshController.text.trim(), - ); ref.read(mediaLibraryProvider.notifier).updateCloudIndexRefreshSchedule(); - StorageManager.set( - StorageKeys.defaultFilePageSize, - _pageSizeController.text.trim(), - ); } String _themeModeToString(ThemeMode mode) { diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 867c0ab..0ce25cd 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -383,7 +383,10 @@ class _WorkspacePageState extends ConsumerState { } void _showSettings(BuildContext context) { - showDialog(context: context, builder: (_) => const SettingsDialog()); + showShadDialog( + context: context, + builder: (_) => const SettingsDialog(), + ); } Widget _buildCloudContent(FileState state) { diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index b97e0e6..c57da38 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -94,25 +94,38 @@ class _ToolHeader extends StatelessWidget { decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.border)), ), - child: Row( - children: [ - ShadButton.ghost( - onPressed: onClose, - leading: const Icon(Icons.arrow_back_rounded, size: 16), - child: const Text('返回'), - ), - const SizedBox(width: 12), - Icon(tool.icon, size: 20, color: cs.primary), - const SizedBox(width: 8), - Text( - tool.title, - style: TextStyle( - color: cs.foreground, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 420; + return Row( + children: [ + ShadTooltip( + builder: (_) => const Text('返回'), + child: ShadButton.ghost( + size: compact ? ShadButtonSize.sm : ShadButtonSize.regular, + onPressed: onClose, + leading: const Icon(Icons.arrow_back_rounded, size: 16), + child: compact ? const SizedBox.shrink() : const Text('返回'), + ), + ), + const SizedBox(width: 10), + Icon(tool.icon, size: 20, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + tool.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.foreground, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, ), ); } @@ -1145,54 +1158,61 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { description: '规则按从上到下的顺序应用。', child: Padding( padding: const EdgeInsets.only(top: 12), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var index = 0; index < _rules.length; index++) - _BatchRenameRuleRow( - key: ValueKey(_rules[index].id), - rule: _rules[index], - isFirst: index == 0, - isLast: index == _rules.length - 1, - onChanged: (rule) => _updateRule(index, rule), - onMoveUp: () => _moveRule(index, -1), - onMoveDown: () => _moveRule(index, 1), - onRemove: () => setState(() { - _rules.removeAt(index); - if (_rules.isEmpty) { + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _BatchRenameRuleRow( + key: ValueKey(_rules[index].id), + rule: _rules[index], + isFirst: index == 0, + isLast: index == _rules.length - 1, + onChanged: (rule) => _updateRule(index, rule), + onMoveUp: () => _moveRule(index, -1), + onMoveDown: () => _moveRule(index, 1), + onRemove: () => setState(() { + _rules.removeAt(index); + if (_rules.isEmpty) { + _rules = const [ + BatchRenameRule( + id: 'rule-0', + kind: BatchRenameRuleKind.replace, + ), + ]; + } + }), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.outline( + onPressed: () => setState(() { + _rules.add( + BatchRenameRule( + id: 'rule-${DateTime.now().microsecondsSinceEpoch}', + kind: BatchRenameRuleKind.replace, + ), + ); + }), + leading: const Icon(Icons.add_rounded, size: 16), + child: const Text('添加规则'), + ), + ShadButton.ghost( + onPressed: () => setState(() { _rules = const [ BatchRenameRule( id: 'rule-0', kind: BatchRenameRuleKind.replace, ), ]; - } - }), - ), - ShadButton.outline( - onPressed: () => setState(() { - _rules.add( - BatchRenameRule( - id: 'rule-${DateTime.now().microsecondsSinceEpoch}', - kind: BatchRenameRuleKind.replace, - ), - ); - }), - leading: const Icon(Icons.add_rounded, size: 16), - child: const Text('添加规则'), - ), - ShadButton.ghost( - onPressed: () => setState(() { - _rules = const [ - BatchRenameRule( - id: 'rule-0', - kind: BatchRenameRuleKind.replace, - ), - ]; - }), - child: const Text('清空规则'), + }), + child: const Text('清空规则'), + ), + ], ), ], ), @@ -1216,43 +1236,51 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { padding: const EdgeInsets.only(top: 12), child: Column( children: [ - Row( - children: [ - ShadButton.outline( - onPressed: _running - ? null - : () => setState(() { - _selectedIDs - ..clear() - ..addAll( - applicable.map((item) => item.file.id), - ); - }), - child: const Text('全选可应用项'), - ), - const SizedBox(width: 8), - ShadButton.ghost( - onPressed: _running - ? null - : () => setState(_selectedIDs.clear), - child: const Text('全不选'), - ), - const Spacer(), - if (_status.isNotEmpty) - Expanded( - child: Text( - _status, - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: ShadTheme.of( - context, - ).colorScheme.mutedForeground, + LayoutBuilder( + builder: (context, constraints) => Wrap( + alignment: WrapAlignment.spaceBetween, + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.outline( + onPressed: _running + ? null + : () => setState(() { + _selectedIDs + ..clear() + ..addAll( + applicable.map((item) => item.file.id), + ); + }), + child: const Text('全选可应用项'), + ), + ShadButton.ghost( + onPressed: _running + ? null + : () => setState(_selectedIDs.clear), + child: const Text('全不选'), + ), + if (_status.isNotEmpty) + SizedBox( + width: constraints.maxWidth < 560 + ? constraints.maxWidth + : 260, + child: Text( + _status, + textAlign: constraints.maxWidth < 560 + ? TextAlign.left + : TextAlign.right, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: ShadTheme.of( + context, + ).colorScheme.mutedForeground, + ), ), ), - ), - ], + ], + ), ), const SizedBox(height: 10), Container( @@ -1274,36 +1302,58 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { ), ), ) - : Column( - children: [ - const _BatchRenamePreviewHeader(), - const Divider(height: 1), - Expanded( - child: ListView.separated( - itemCount: previews.length, - separatorBuilder: (_, _) => - const Divider(height: 1), - itemBuilder: (context, index) { - final preview = previews[index]; - final canSelect = preview.applicable; - return _BatchRenamePreviewRow( - preview: preview, - selected: _selectedIDs.contains( - preview.file.id, - ), - enabled: canSelect && !_running, - onChanged: (selected) => setState(() { - if (selected == true && canSelect) { - _selectedIDs.add(preview.file.id); - } else { - _selectedIDs.remove(preview.file.id); - } - }), - ); - }, + : LayoutBuilder( + builder: (context, constraints) { + final tableWidth = constraints.maxWidth < 920 + ? 920.0 + : constraints.maxWidth; + return Scrollbar( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + const _BatchRenamePreviewHeader(), + const Divider(height: 1), + Expanded( + child: ListView.separated( + itemCount: previews.length, + separatorBuilder: (_, _) => + const Divider(height: 1), + itemBuilder: (context, index) { + final preview = previews[index]; + final canSelect = + preview.applicable; + return _BatchRenamePreviewRow( + preview: preview, + selected: _selectedIDs.contains( + preview.file.id, + ), + enabled: canSelect && !_running, + onChanged: (selected) => + setState(() { + if (selected == true && + canSelect) { + _selectedIDs.add( + preview.file.id, + ); + } else { + _selectedIDs.remove( + preview.file.id, + ); + } + }), + ); + }, + ), + ), + ], + ), + ), ), - ), - ], + ); + }, ), ), ], @@ -1448,72 +1498,82 @@ class _BatchRenameFolderPickerState child: const Text('使用此目录'), ), ], - child: SizedBox( - width: 540, - height: 360, - child: Column( - children: [ - Row( + child: LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 560; + return SizedBox( + width: narrow ? constraints.maxWidth : 540, + height: narrow ? 420 : 360, + child: Column( children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: _path.isEmpty - ? null - : () { - setState(() => _path.removeLast()); - _load(); - }, - leading: const Icon(Icons.arrow_back_rounded, size: 16), - child: const Text('返回上级'), + Row( + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: _path.isEmpty + ? null + : () { + setState(() => _path.removeLast()); + _load(); + }, + leading: const Icon(Icons.arrow_back_rounded, size: 16), + child: narrow + ? const SizedBox.shrink() + : const Text('返回上级'), + ), + const Spacer(), + ShadTooltip( + builder: (_) => const Text('刷新文件夹'), + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: _loading ? null : _load, + child: const Icon(Icons.refresh_rounded, size: 16), + ), + ), + ], ), - const Spacer(), - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: _loading ? null : _load, - child: const Icon(Icons.refresh_rounded, size: 16), + const SizedBox(height: 8), + Expanded( + child: _loading + ? const Center(child: ShadProgress()) + : _error != null + ? Center( + child: Text( + _error!, + style: TextStyle(color: cs.destructive), + ), + ) + : _folders.isEmpty + ? Center( + child: Text( + '没有子文件夹', + style: TextStyle(color: cs.mutedForeground), + ), + ) + : ListView.separated( + itemCount: _folders.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final folder = _folders[index]; + return ListTile( + leading: Icon( + Icons.folder_rounded, + color: cs.primary, + ), + title: Text(folder.name), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + setState(() => _path.add(folder)); + _load(); + }, + ); + }, + ), ), ], ), - const SizedBox(height: 8), - Expanded( - child: _loading - ? const Center(child: ShadProgress()) - : _error != null - ? Center( - child: Text( - _error!, - style: TextStyle(color: cs.destructive), - ), - ) - : _folders.isEmpty - ? Center( - child: Text( - '没有子文件夹', - style: TextStyle(color: cs.mutedForeground), - ), - ) - : ListView.separated( - itemCount: _folders.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final folder = _folders[index]; - return ListTile( - leading: Icon( - Icons.folder_rounded, - color: cs.primary, - ), - title: Text(folder.name), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () { - setState(() => _path.add(folder)); - _load(); - }, - ); - }, - ), - ), - ], - ), + ); + }, ), ); } @@ -1547,91 +1607,84 @@ class _BatchRenameRuleRow extends StatelessWidget { rule.kind == BatchRenameRuleKind.regex; final supportsCase = hasReplacement || rule.kind == BatchRenameRuleKind.remove; - return IntrinsicWidth( - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: cs.muted.withValues(alpha: 0.46), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: cs.border.withValues(alpha: 0.65)), - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ShadCheckbox( - value: rule.enabled, - label: const Text('启用'), - onChanged: (value) => onChanged(rule.copyWith(enabled: value)), + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: cs.muted.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.border.withValues(alpha: 0.65)), + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ShadCheckbox( + value: rule.enabled, + label: const Text('启用'), + onChanged: (value) => onChanged(rule.copyWith(enabled: value)), + ), + SizedBox( + width: 132, + child: ShadSelect( + key: ValueKey('${rule.id}-${rule.kind}'), + initialValue: rule.kind, + selectedOptionBuilder: (_, value) => Text(_ruleKindLabel(value)), + options: [ + for (final value in BatchRenameRuleKind.values) + ShadOption(value: value, child: Text(_ruleKindLabel(value))), + ], + onChanged: (value) { + if (value != null) onChanged(rule.copyWith(kind: value)); + }, ), - SizedBox( - width: 132, - child: ShadSelect( - key: ValueKey('${rule.id}-${rule.kind}'), - initialValue: rule.kind, - selectedOptionBuilder: (_, value) => - Text(_ruleKindLabel(value)), - options: [ - for (final value in BatchRenameRuleKind.values) - ShadOption( - value: value, - child: Text(_ruleKindLabel(value)), - ), - ], - onChanged: (value) { - if (value != null) onChanged(rule.copyWith(kind: value)); - }, - ), + ), + SizedBox( + width: 190, + child: ShadInput( + initialValue: rule.pattern, + placeholder: Text(_rulePatternPlaceholder(rule.kind)), + onChanged: (value) => onChanged(rule.copyWith(pattern: value)), ), + ), + if (hasReplacement) ...[ + const Icon(Icons.arrow_right_alt_rounded, size: 18), SizedBox( - width: 190, + width: 160, child: ShadInput( - initialValue: rule.pattern, - placeholder: Text(_rulePatternPlaceholder(rule.kind)), - onChanged: (value) => onChanged(rule.copyWith(pattern: value)), - ), - ), - if (hasReplacement) ...[ - const Icon(Icons.arrow_right_alt_rounded, size: 18), - SizedBox( - width: 160, - child: ShadInput( - initialValue: rule.replacement, - placeholder: const Text('替换为'), - onChanged: (value) => - onChanged(rule.copyWith(replacement: value)), - ), - ), - ], - if (supportsCase) - ShadCheckbox( - value: rule.ignoreCase, - label: const Text('忽略大小写'), + initialValue: rule.replacement, + placeholder: const Text('替换为'), onChanged: (value) => - onChanged(rule.copyWith(ignoreCase: value)), + onChanged(rule.copyWith(replacement: value)), ), - _RenameRuleIconButton( - icon: Icons.arrow_upward_rounded, - tooltip: '上移规则', - enabled: !isFirst, - onPressed: onMoveUp, - ), - _RenameRuleIconButton( - icon: Icons.arrow_downward_rounded, - tooltip: '下移规则', - enabled: !isLast, - onPressed: onMoveDown, - ), - _RenameRuleIconButton( - icon: Icons.delete_outline_rounded, - tooltip: '删除规则', - enabled: true, - destructive: true, - onPressed: onRemove, ), ], - ), + if (supportsCase) + ShadCheckbox( + value: rule.ignoreCase, + label: const Text('忽略大小写'), + onChanged: (value) => onChanged(rule.copyWith(ignoreCase: value)), + ), + _RenameRuleIconButton( + icon: Icons.arrow_upward_rounded, + tooltip: '上移规则', + enabled: !isFirst, + onPressed: onMoveUp, + ), + _RenameRuleIconButton( + icon: Icons.arrow_downward_rounded, + tooltip: '下移规则', + enabled: !isLast, + onPressed: onMoveDown, + ), + _RenameRuleIconButton( + icon: Icons.delete_outline_rounded, + tooltip: '删除规则', + enabled: true, + destructive: true, + onPressed: onRemove, + ), + ], ), ); } diff --git a/lib/widgets/app_log_dialog.dart b/lib/widgets/app_log_dialog.dart index ef8e747..a6582bb 100644 --- a/lib/widgets/app_log_dialog.dart +++ b/lib/widgets/app_log_dialog.dart @@ -9,6 +9,8 @@ class AppLogDialog extends StatelessWidget { @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; + final screen = MediaQuery.sizeOf(context); + final compact = screen.width < 720; return ShadDialog( title: const Text('运行日志'), description: const Text('实时显示当前会话日志;RELEASE 同时写入本地日志文件。'), @@ -26,8 +28,8 @@ class AppLogDialog extends StatelessWidget { ), ], child: SizedBox( - width: 840, - height: 520, + width: compact ? screen.width - 32 : 840, + height: compact ? (screen.height * 0.56).clamp(280, 440) : 520, child: ValueListenableBuilder>( valueListenable: AppLogger.entries, builder: (_, entries, _) { diff --git a/lib/widgets/breadcrumb_bar.dart b/lib/widgets/breadcrumb_bar.dart index 5696892..1e1392d 100644 --- a/lib/widgets/breadcrumb_bar.dart +++ b/lib/widgets/breadcrumb_bar.dart @@ -17,27 +17,12 @@ class BreadcrumbBar extends StatelessWidget { final cs = ShadTheme.of(context).colorScheme; final items = [ - // Home button - GestureDetector( + _BreadcrumbItem( + label: '云盘根目录', + text: '云盘', + selected: path.isEmpty, + enabled: true, onTap: () => onNavigate(-1), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: path.isEmpty ? cs.primary.withAlpha(15) : null, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '云盘', - style: TextStyle( - fontSize: 13, - fontWeight: path.isEmpty ? FontWeight.w600 : FontWeight.normal, - color: path.isEmpty ? cs.primary : cs.mutedForeground, - ), - ), - ), - ), ), ]; @@ -51,28 +36,12 @@ class BreadcrumbBar extends StatelessWidget { ), ); items.add( - GestureDetector( - onTap: isLast ? null : () => onNavigate(i), - child: MouseRegion( - cursor: isLast - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isLast ? cs.primary.withAlpha(15) : null, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - path[i].name, - style: TextStyle( - fontSize: 13, - fontWeight: isLast ? FontWeight.w600 : FontWeight.normal, - color: isLast ? cs.primary : cs.mutedForeground, - ), - ), - ), - ), + _BreadcrumbItem( + label: path[i].name, + text: path[i].name, + selected: isLast, + enabled: !isLast, + onTap: () => onNavigate(i), ), ); } @@ -90,3 +59,53 @@ class BreadcrumbBar extends StatelessWidget { ); } } + +class _BreadcrumbItem extends StatelessWidget { + final String label; + final String text; + final bool selected; + final bool enabled; + final VoidCallback onTap; + + const _BreadcrumbItem({ + required this.label, + required this.text, + required this.selected, + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Semantics( + button: enabled, + selected: selected, + label: label, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selected ? cs.primary.withAlpha(15) : null, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? cs.primary : cs.mutedForeground, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/file_list_tile.dart b/lib/widgets/file_list_tile.dart index a79d09f..c33929d 100644 --- a/lib/widgets/file_list_tile.dart +++ b/lib/widgets/file_list_tile.dart @@ -206,124 +206,132 @@ class _FileListTileState extends State { ), ], ], - child: GestureDetector( - onTap: _isRenaming ? null : widget.onSelect, - onDoubleTap: _isRenaming ? null : widget.onOpen, - child: Container( - height: compact ? 74 : 62, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: widget.isSelected - ? cs.primary.withValues(alpha: 0.14) - : cs.card, - border: Border(bottom: BorderSide(color: cs.border, width: 0.5)), - ), - 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, + child: Semantics( + button: !_isRenaming, + selected: widget.isSelected, + label: + '${widget.file.isDirectory ? '文件夹' : '文件'} ${widget.file.name}${widget.file.isDirectory ? '' : ',${widget.file.formattedSize}'}', + hint: _isRenaming ? '正在重命名' : '点按选择,双击打开', + child: GestureDetector( + onTap: _isRenaming ? null : widget.onSelect, + onDoubleTap: _isRenaming ? null : widget.onOpen, + child: Container( + height: compact ? 74 : 62, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: widget.isSelected + ? cs.primary.withValues(alpha: 0.14) + : cs.card, + border: Border(bottom: BorderSide(color: cs.border, width: 0.5)), + ), + 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: 32, + height: 32, + child: FileIcon(file: widget.file), ), - 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, + const SizedBox(width: 12), + Expanded( + child: _isRenaming + ? Row( + children: [ + Expanded( + child: ShadInput( + controller: _renameController, + focusNode: _renameFocusNode, + autofocus: true, + enabled: !_isSubmittingRename, + onSubmitted: (_) => _confirmRename(), ), ), - ], - ), - ), - SizedBox( - width: 80, - child: Text( - widget.file.formattedSize, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.mutedForeground, + 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, + 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, ), - overflow: TextOverflow.ellipsis, ), - ), - ], - ), + ], + ), + ), ), ), ); diff --git a/lib/widgets/media_player_dialog.dart b/lib/widgets/media_player_dialog.dart index 8d696d9..abe5c07 100644 --- a/lib/widgets/media_player_dialog.dart +++ b/lib/widgets/media_player_dialog.dart @@ -12,6 +12,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; +import '../core/logging/app_logger.dart'; import '../providers/file_provider.dart'; Future showMediaPlayerDialog( @@ -39,7 +40,9 @@ Future showMediaPlayerDialog( onPlaybackFailure: openExternalPlayer, ), ); - } catch (_) { + } catch (error, stackTrace) { + AppLogger.warning('Player', '内置播放器窗口异常,正在打开外部播放器:$error'); + AppLogger.debug('Player', stackTrace.toString()); await openExternalPlayer(); } } @@ -168,6 +171,7 @@ class _MediaPlayerDialogState extends ConsumerState { } void _handlePlaybackFailure(Object error, String message) { + AppLogger.warning('Player', '$message,准备切换外部播放器:$error'); if (!mounted) return; setState(() { _error = '$message:$error'; @@ -282,28 +286,32 @@ class _MediaPlayerDialogState extends ConsumerState { Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final screen = MediaQuery.sizeOf(context); - final sideWidth = _showEpisodes ? 250.0 : 0.0; - const dialogPadding = 40.0; - final maxDialogWidth = math.max(400.0, screen.width - 32); - final maxVideoWidth = math.max( - 360.0, - maxDialogWidth - sideWidth - dialogPadding, + final compact = screen.width < 600; + final sideWidth = _showEpisodes && !compact ? 250.0 : 0.0; + final maxDialogWidth = math.max(1.0, screen.width - 24); + final maxVideoWidth = math.max(1.0, maxDialogWidth - sideWidth - 24); + final minVideoWidth = math.min(compact ? 240.0 : 360.0, maxVideoWidth); + final minVideoHeight = compact ? 160.0 : 240.0; + final maxVideoHeight = math.max( + minVideoHeight, + screen.height - (compact ? 230 : 290), ); - final maxVideoHeight = math.max(260.0, screen.height - 290); final preferredWidth = _hasVideoDimensions ? math.min(900.0, _videoAspectRatio * maxVideoHeight) : 600.0; - final videoWidth = preferredWidth.clamp(360.0, maxVideoWidth).toDouble(); + final videoWidth = preferredWidth + .clamp(minVideoWidth, maxVideoWidth) + .toDouble(); final videoHeight = (videoWidth / _videoAspectRatio) - .clamp(240.0, maxVideoHeight) + .clamp(minVideoHeight, maxVideoHeight) .toDouble(); final contentWidth = videoWidth + sideWidth; return ShadDialog( constraints: BoxConstraints( - maxWidth: math.min(maxDialogWidth, contentWidth + dialogPadding), - maxHeight: math.max(360, screen.height - 24), + maxWidth: math.min(maxDialogWidth, contentWidth + 24), + maxHeight: math.max(260, screen.height - 16), ), - padding: const EdgeInsets.all(20), + padding: EdgeInsets.all(compact ? 12 : 20), scrollable: false, title: Text( _currentFile.name, @@ -315,6 +323,8 @@ class _MediaPlayerDialogState extends ConsumerState { ShadButton.outline( onPressed: _episodes.length < 2 ? null + : compact + ? _showEpisodesSheet : () => setState(() => _showEpisodes = !_showEpisodes), leading: const Icon(Icons.format_list_bulleted_rounded, size: 16), child: const Text('同目录剧集'), @@ -381,6 +391,19 @@ class _MediaPlayerDialogState extends ConsumerState { ); } + Future _showEpisodesSheet() => showShadSheet( + context: context, + side: ShadSheetSide.bottom, + builder: (_) => ShadSheet( + constraints: const BoxConstraints(maxHeight: 520), + title: const Text('同目录剧集'), + child: SizedBox( + height: 360, + child: _episodeList(ShadTheme.of(context).colorScheme), + ), + ), + ); + Widget _episodeList(ShadColorScheme cs) { return DecoratedBox( decoration: BoxDecoration(