audit responsive workspace and interaction UI

This commit is contained in:
ngfchl
2026-07-19 00:14:00 +08:00
parent be64bd59c4
commit 80f0db6996
11 changed files with 696 additions and 544 deletions
+14 -4
View File
@@ -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<GuangyaApp> createState() => _GuangyaAppState();
}
class _GuangyaAppState extends ConsumerState<GuangyaApp> {
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);
+1
View File
@@ -37,6 +37,7 @@ class AppLogEntry {
'Auth' => '认证',
'Media' => '媒体库',
'CloudIndex' => '全盘索引',
'Player' => '播放器',
'App' => '应用',
'Flutter' => '界面',
'Dart' => '运行时',
+36 -40
View File
@@ -27,23 +27,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
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<LoginPage> {
// 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,
),
),
),
),
),
+55 -25
View File
@@ -252,10 +252,12 @@ class _FileSearchResultsPageState extends ConsumerState<FileSearchResultsPage> {
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),
+53 -46
View File
@@ -72,6 +72,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
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<SettingsDialog> {
),
],
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<SettingsDialog> {
);
}
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<SettingsDialog> {
);
Future<void> _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) {
+4 -1
View File
@@ -383,7 +383,10 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
}
void _showSettings(BuildContext context) {
showDialog(context: context, builder: (_) => const SettingsDialog());
showShadDialog<void>(
context: context,
builder: (_) => const SettingsDialog(),
);
}
Widget _buildCloudContent(FileState state) {
+314 -261
View File
@@ -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<BatchRenameRuleKind>(
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<BatchRenameRuleKind>(
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,
),
],
),
);
}
+4 -2
View File
@@ -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<List<AppLogEntry>>(
valueListenable: AppLogger.entries,
builder: (_, entries, _) {
+61 -42
View File
@@ -17,27 +17,12 @@ class BreadcrumbBar extends StatelessWidget {
final cs = ShadTheme.of(context).colorScheme;
final items = <Widget>[
// 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,
),
),
),
),
),
);
}
}
+118 -110
View File
@@ -206,124 +206,132 @@ class _FileListTileState extends State<FileListTile> {
),
],
],
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,
),
),
],
),
],
),
),
),
),
);
+36 -13
View File
@@ -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<void> showMediaPlayerDialog(
@@ -39,7 +40,9 @@ Future<void> 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<MediaPlayerDialog> {
}
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<MediaPlayerDialog> {
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<MediaPlayerDialog> {
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<MediaPlayerDialog> {
);
}
Future<void> _showEpisodesSheet() => showShadSheet<void>(
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(