feat: unp4k UI update

This commit is contained in:
xkeyC
2025-12-04 17:43:12 +08:00
parent aaa97429b9
commit 1c4eafccca
13 changed files with 1632 additions and 254 deletions

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:file_sizes/file_sizes.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -58,183 +59,658 @@ class UnP4kcUI extends HookConsumerWidget {
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
: Stack(
children: [
Container(
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .06)),
height: 36,
padding: const EdgeInsets.only(left: 12, right: 12),
child: SuperListView.builder(
itemCount: paths.length - 1,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
var path = paths[index];
if (path.isEmpty) {
path = "\\";
}
final fullPath = "${paths.sublist(0, index + 1).join("\\")}\\";
return Row(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .06)),
height: 36,
padding: const EdgeInsets.only(left: 12, right: 12),
child: Row(
children: [
IconButton(
icon: Text(path),
onPressed: () {
model.changeDir(fullPath, fullPath: true);
},
// 搜索模式下显示返回按钮
if (state.searchFs != null) ...[
IconButton(
icon: const Icon(FluentIcons.back, size: 14),
onPressed: () {
model.clearSearch();
},
),
const SizedBox(width: 8),
Text(
"[${S.current.tools_unp4k_searching.replaceAll('...', '')}]",
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .7)),
),
const SizedBox(width: 8),
],
Expanded(
child: SuperListView.builder(
itemCount: paths.length - 1,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
var path = paths[index];
if (path.isEmpty) {
path = "\\";
}
final fullPath = "${paths.sublist(0, index + 1).join("\\")}\\";
return Row(
children: [
IconButton(
icon: Text(path),
onPressed: () {
model.changeDir(fullPath, fullPath: true);
},
),
const Icon(FluentIcons.chevron_right, size: 12),
],
);
},
),
),
const Icon(FluentIcons.chevron_right, size: 12),
],
);
},
),
),
Expanded(
child: Row(
children: [
Container(
width: MediaQuery.of(context).size.width * .3,
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .01)),
child: SuperListView.builder(
padding: const EdgeInsets.only(top: 6, bottom: 6, left: 3, right: 12),
itemBuilder: (BuildContext context, int index) {
final item = files![index];
final fileName = item.name?.replaceAll(state.curPath.trim(), "") ?? "?";
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .05)),
child: IconButton(
onPressed: () {
if (item.isDirectory ?? false) {
model.changeDir(fileName);
} else {
model.openFile(item.name ?? "");
}
},
icon: Padding(
padding: const EdgeInsets.only(left: 4, right: 4),
child: Row(
children: [
if (item.isDirectory ?? false)
const Icon(FluentIcons.folder_fill, color: Color.fromRGBO(255, 224, 138, 1))
else if (fileName.endsWith(".xml"))
const Icon(FluentIcons.file_code)
else
const Icon(FluentIcons.open_file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
fileName,
style: const TextStyle(fontSize: 13),
textAlign: TextAlign.start,
),
),
],
),
if (!(item.isDirectory ?? true)) ...[
const SizedBox(height: 1),
Row(
),
),
Expanded(
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * .3,
child: _FileListPanel(state: state, model: model, files: files),
),
Expanded(
child: state.tempOpenFile == null
? Center(child: Text(S.current.tools_unp4k_view_file))
: state.tempOpenFile?.key == "loading"
? makeLoading(context)
: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
if (state.tempOpenFile?.key == "text")
Expanded(child: _TextTempWidget(state.tempOpenFile?.value ?? ""))
else
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
FileSize.getSize(item.size),
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: .6),
S.current.tools_unp4k_msg_unknown_file_type(
state.tempOpenFile?.value ?? "",
),
),
const SizedBox(width: 12),
Text(
item.dateModified != null
? DateTime.fromMillisecondsSinceEpoch(
item.dateModified!,
).toString().substring(0, 19)
: "",
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: .6),
const SizedBox(height: 32),
FilledButton(
child: Padding(
padding: const EdgeInsets.all(4),
child: Text(S.current.action_open_folder),
),
onPressed: () {
SystemHelper.openDir(state.tempOpenFile?.value ?? "");
},
),
],
),
],
],
),
),
const SizedBox(width: 3),
Icon(
FluentIcons.chevron_right,
size: 14,
color: Colors.white.withValues(alpha: .6),
),
],
),
),
),
);
},
itemCount: files?.length ?? 0,
),
),
Expanded(
child: Container(
child: state.tempOpenFile == null
? Center(child: Text(S.current.tools_unp4k_view_file))
: state.tempOpenFile?.key == "loading"
? makeLoading(context)
: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
if (state.tempOpenFile?.key == "text")
Expanded(child: _TextTempWidget(state.tempOpenFile?.value ?? ""))
else
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.current.tools_unp4k_msg_unknown_file_type(
state.tempOpenFile?.value ?? "",
),
),
const SizedBox(height: 32),
FilledButton(
child: Padding(
padding: const EdgeInsets.all(4),
child: Text(S.current.action_open_folder),
),
onPressed: () {
SystemHelper.openDir(state.tempOpenFile?.value ?? "");
},
),
],
),
),
),
],
],
),
),
),
),
),
],
),
],
),
),
if (state.endMessage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text("${state.endMessage}", style: const TextStyle(fontSize: 12)),
),
],
),
if (state.endMessage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text("${state.endMessage}", style: const TextStyle(fontSize: 12)),
// 搜索加载遮罩
if (state.isSearching)
Container(
color: Colors.black.withValues(alpha: .7),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ProgressRing(),
const SizedBox(height: 16),
Text(S.current.tools_unp4k_searching, style: const TextStyle(fontSize: 16)),
],
),
),
),
],
);
}
}
/// 文件列表面板组件
class _FileListPanel extends HookConsumerWidget {
final Unp4kcState state;
final Unp4kCModel model;
final List<AppUnp4kP4kItemData>? files;
const _FileListPanel({required this.state, required this.model, required this.files});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController(text: state.searchQuery);
return Container(
decoration: BoxDecoration(color: FluentTheme.of(context).cardColor.withValues(alpha: .01)),
child: Column(
children: [
// 搜索栏和排序选择器
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextBox(
controller: searchController,
placeholder: S.current.tools_unp4k_search_placeholder,
prefix: const Padding(padding: EdgeInsets.only(left: 8), child: Icon(FluentIcons.search, size: 14)),
suffix: searchController.text.isNotEmpty || state.searchFs != null
? IconButton(
icon: const Icon(FluentIcons.clear, size: 12),
onPressed: () {
searchController.clear();
model.clearSearch();
},
)
: null,
onSubmitted: (value) {
model.search(value);
},
),
),
const SizedBox(width: 8),
ComboBox<Unp4kSortType>(
value: state.sortType,
items: [
ComboBoxItem(value: Unp4kSortType.defaultSort, child: Text(S.current.tools_unp4k_sort_default)),
ComboBoxItem(value: Unp4kSortType.sizeAsc, child: Text(S.current.tools_unp4k_sort_size_asc)),
ComboBoxItem(value: Unp4kSortType.sizeDesc, child: Text(S.current.tools_unp4k_sort_size_desc)),
ComboBoxItem(value: Unp4kSortType.dateAsc, child: Text(S.current.tools_unp4k_sort_date_asc)),
ComboBoxItem(value: Unp4kSortType.dateDesc, child: Text(S.current.tools_unp4k_sort_date_desc)),
],
onChanged: (value) {
if (value != null) {
model.setSortType(value);
}
},
),
],
),
),
// 多选模式工具栏
if (state.isMultiSelectMode)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: FluentTheme.of(context).accentColor.withValues(alpha: .1),
border: Border(
bottom: BorderSide(color: FluentTheme.of(context).accentColor.withValues(alpha: .3), width: 1),
),
),
child: Row(
children: [
Text(
S.current.tools_unp4k_action_export_selected(state.selectedItems.length),
style: const TextStyle(fontSize: 12),
),
const Spacer(),
Button(child: Text(S.current.tools_unp4k_action_select_all), onPressed: () => model.selectAll(files)),
const SizedBox(width: 4),
Button(
child: Text(S.current.tools_unp4k_action_deselect_all),
onPressed: () => model.deselectAll(files),
),
const SizedBox(width: 4),
FilledButton(
onPressed: state.selectedItems.isNotEmpty ? () => _exportSelected(context) : null,
child: Text(S.current.tools_unp4k_action_save_as),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(FluentIcons.cancel, size: 14),
onPressed: () => model.exitMultiSelectMode(),
),
],
),
),
// 文件列表
Expanded(
child: files == null || files!.isEmpty
? Center(
child: Text(
state.searchFs != null ? S.current.tools_unp4k_search_no_result : '',
style: TextStyle(color: Colors.white.withValues(alpha: .6)),
),
)
: SuperListView.builder(
padding: const EdgeInsets.only(top: 6, bottom: 6, left: 3, right: 12),
itemBuilder: (BuildContext context, int index) {
final item = files![index];
return _FileListItem(item: item, state: state, model: model);
},
itemCount: files?.length ?? 0,
),
),
],
),
);
}
Future<void> _exportSelected(BuildContext context) async {
final outputDir = await FilePicker.platform.getDirectoryPath(dialogTitle: S.current.tools_unp4k_action_save_as);
if (outputDir != null && context.mounted) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return _MultiExtractProgressDialog(selectedItems: state.selectedItems, outputDir: outputDir, model: model);
},
);
// 提取完成后退出多选模式
model.exitMultiSelectMode();
}
}
}
/// 文件列表项组件
class _FileListItem extends HookWidget {
final AppUnp4kP4kItemData item;
final Unp4kcState state;
final Unp4kCModel model;
const _FileListItem({required this.item, required this.state, required this.model});
@override
Widget build(BuildContext context) {
final flyoutController = useMemoized(() => FlyoutController());
// 显示相对于当前路径的文件名
final fileName = item.name?.replaceAll(state.curPath.trim(), "") ?? "?";
final itemPath = item.name ?? "";
final isSelected = state.selectedItems.contains(itemPath);
return FlyoutTarget(
controller: flyoutController,
child: GestureDetector(
onSecondaryTapUp: (details) => _showContextMenu(context, flyoutController),
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
color: isSelected
? FluentTheme.of(context).accentColor.withValues(alpha: .2)
: FluentTheme.of(context).cardColor.withValues(alpha: .05),
),
child: IconButton(
onPressed: () {
if (state.isMultiSelectMode) {
// 多选模式下点击切换选中状态
model.toggleSelectItem(itemPath);
} else if (item.isDirectory ?? false) {
final dirName = item.name?.replaceAll(state.curPath.trim(), "") ?? "";
model.changeDir(dirName);
} else {
model.openFile(item.name ?? "");
}
},
icon: Padding(
padding: const EdgeInsets.only(left: 4, right: 4),
child: Row(
children: [
// 多选模式下显示复选框
if (state.isMultiSelectMode) ...[
Checkbox(
checked: isSelected,
onChanged: (value) {
model.toggleSelectItem(itemPath);
},
),
const SizedBox(width: 8),
],
if (item.isDirectory ?? false)
const Icon(FluentIcons.folder_fill, color: Color.fromRGBO(255, 224, 138, 1))
else if (fileName.endsWith(".xml"))
const Icon(FluentIcons.file_code)
else
const Icon(FluentIcons.open_file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(fileName, style: const TextStyle(fontSize: 13), textAlign: TextAlign.start),
),
],
),
if (!(item.isDirectory ?? true)) ...[
const SizedBox(height: 1),
Row(
children: [
Text(
FileSize.getSize(item.size),
style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)),
),
const SizedBox(width: 12),
Text(
item.dateModified != null
? DateTime.fromMillisecondsSinceEpoch(
item.dateModified!,
).toString().substring(0, 19)
: "",
style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .6)),
),
],
),
],
],
),
),
const SizedBox(width: 3),
Icon(FluentIcons.chevron_right, size: 14, color: Colors.white.withValues(alpha: .6)),
],
),
),
),
),
),
);
}
void _showContextMenu(BuildContext context, FlyoutController controller) {
// 保存外部 context因为 flyout 的 context 在关闭后会失效
final outerContext = context;
controller.showFlyout(
autoModeConfiguration: FlyoutAutoConfiguration(preferredMode: FlyoutPlacementMode.bottomCenter),
barrierColor: Colors.transparent,
builder: (flyoutContext) {
return MenuFlyout(
items: [
MenuFlyoutItem(
leading: const Icon(FluentIcons.save_as, size: 16),
text: Text(S.current.tools_unp4k_action_save_as),
onPressed: () async {
Navigator.of(flyoutContext).pop();
await _saveAs(outerContext);
},
),
// 多选模式切换
if (!state.isMultiSelectMode)
MenuFlyoutItem(
leading: const Icon(FluentIcons.checkbox_composite, size: 16),
text: Text(S.current.tools_unp4k_action_multi_select),
onPressed: () {
Navigator.of(flyoutContext).pop();
model.enterMultiSelectMode();
// 自动选中当前项
model.toggleSelectItem(item.name ?? "");
},
),
],
);
},
);
}
Future<void> _saveAs(BuildContext context) async {
final outputDir = await FilePicker.platform.getDirectoryPath(dialogTitle: S.current.tools_unp4k_action_save_as);
if (outputDir != null && context.mounted) {
await _showExtractProgressDialog(context, outputDir);
}
}
Future<void> _showExtractProgressDialog(BuildContext context, String outputDir) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return _ExtractProgressDialog(item: item, outputDir: outputDir, model: model);
},
);
}
}
/// 提取进度对话框
class _ExtractProgressDialog extends HookWidget {
final AppUnp4kP4kItemData item;
final String outputDir;
final Unp4kCModel model;
const _ExtractProgressDialog({required this.item, required this.outputDir, required this.model});
@override
Widget build(BuildContext context) {
final isCancelled = useState(false);
final currentFile = useState("");
final currentIndex = useState(0);
final totalFiles = useState(0);
final isCompleted = useState(false);
final errorMessage = useState<String?>(null);
final extractedCount = useState(0);
useEffect(() {
// 获取文件数量
totalFiles.value = model.getFileCountInDirectory(item);
// 开始提取
_startExtraction(isCancelled, currentFile, currentIndex, totalFiles, isCompleted, errorMessage, extractedCount);
return null;
}, const []);
final progress = totalFiles.value > 0 ? currentIndex.value / totalFiles.value : 0.0;
return ContentDialog(
title: Text(S.current.tools_unp4k_extract_dialog_title),
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 300),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isCompleted.value && errorMessage.value == null) ...[
// 进度条
ProgressBar(value: progress * 100),
const SizedBox(height: 12),
// 进度文本
Text(
S.current.tools_unp4k_extract_progress(currentIndex.value, totalFiles.value),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
// 当前文件
Text(
S.current.tools_unp4k_extract_current_file(
currentFile.value.length > 60
? "...${currentFile.value.substring(currentFile.value.length - 60)}"
: currentFile.value,
),
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .7)),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
] else if (errorMessage.value != null) ...[
// 错误信息
const Icon(FluentIcons.error_badge, size: 48, color: Color(0xFFE81123)),
const SizedBox(height: 12),
Text(errorMessage.value!, style: const TextStyle(fontSize: 14)),
] else ...[
// 完成
const Icon(FluentIcons.completed_solid, size: 48, color: Color(0xFF107C10)),
const SizedBox(height: 12),
Text(S.current.tools_unp4k_extract_completed(extractedCount.value), style: const TextStyle(fontSize: 14)),
],
],
),
actions: [
if (!isCompleted.value && errorMessage.value == null)
Button(
onPressed: () {
isCancelled.value = true;
},
child: Text(S.current.home_action_cancel),
)
else
FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(S.current.action_close)),
],
);
}
Future<void> _startExtraction(
ValueNotifier<bool> isCancelled,
ValueNotifier<String> currentFile,
ValueNotifier<int> currentIndex,
ValueNotifier<int> totalFiles,
ValueNotifier<bool> isCompleted,
ValueNotifier<String?> errorMessage,
ValueNotifier<int> extractedCount,
) async {
final result = await model.extractToDirectoryWithProgress(
item,
outputDir,
onProgress: (current, total, file) {
currentIndex.value = current;
totalFiles.value = total;
currentFile.value = file;
},
isCancelled: () => isCancelled.value,
);
final (success, count, error) = result;
extractedCount.value = count;
if (!success && error != null) {
errorMessage.value = error;
} else {
isCompleted.value = true;
}
}
}
/// 批量提取进度对话框
class _MultiExtractProgressDialog extends HookWidget {
final Set<String> selectedItems;
final String outputDir;
final Unp4kCModel model;
const _MultiExtractProgressDialog({required this.selectedItems, required this.outputDir, required this.model});
@override
Widget build(BuildContext context) {
final isCancelled = useState(false);
final currentFile = useState("");
final currentIndex = useState(0);
final totalFiles = useState(0);
final isCompleted = useState(false);
final errorMessage = useState<String?>(null);
final extractedCount = useState(0);
useEffect(() {
// 获取文件数量
totalFiles.value = model.getFileCountForSelectedItems(selectedItems);
// 开始提取
_startExtraction(isCancelled, currentFile, currentIndex, totalFiles, isCompleted, errorMessage, extractedCount);
return null;
}, const []);
final progress = totalFiles.value > 0 ? currentIndex.value / totalFiles.value : 0.0;
return ContentDialog(
title: Text(S.current.tools_unp4k_extract_dialog_title),
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 300),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isCompleted.value && errorMessage.value == null) ...[
// 进度条
ProgressBar(value: progress * 100),
const SizedBox(height: 12),
// 进度文本
Text(
S.current.tools_unp4k_extract_progress(currentIndex.value, totalFiles.value),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
// 当前文件
Text(
S.current.tools_unp4k_extract_current_file(
currentFile.value.length > 60
? "...${currentFile.value.substring(currentFile.value.length - 60)}"
: currentFile.value,
),
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .7)),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
] else if (errorMessage.value != null) ...[
// 错误信息
const Icon(FluentIcons.error_badge, size: 48, color: Color(0xFFE81123)),
const SizedBox(height: 12),
Text(errorMessage.value!, style: const TextStyle(fontSize: 14)),
] else ...[
// 完成
const Icon(FluentIcons.completed_solid, size: 48, color: Color(0xFF107C10)),
const SizedBox(height: 12),
Text(S.current.tools_unp4k_extract_completed(extractedCount.value), style: const TextStyle(fontSize: 14)),
],
],
),
actions: [
if (!isCompleted.value && errorMessage.value == null)
Button(
onPressed: () {
isCancelled.value = true;
},
child: Text(S.current.home_action_cancel),
)
else
FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(S.current.action_close)),
],
);
}
Future<void> _startExtraction(
ValueNotifier<bool> isCancelled,
ValueNotifier<String> currentFile,
ValueNotifier<int> currentIndex,
ValueNotifier<int> totalFiles,
ValueNotifier<bool> isCompleted,
ValueNotifier<String?> errorMessage,
ValueNotifier<int> extractedCount,
) async {
final result = await model.extractSelectedItemsWithProgress(
selectedItems,
outputDir,
onProgress: (current, total, file) {
currentIndex.value = current;
totalFiles.value = total;
currentFile.value = file;
},
isCancelled: () => isCancelled.value,
);
final (success, count, error) = result;
extractedCount.value = count;
if (!success && error != null) {
errorMessage.value = error;
} else {
isCompleted.value = true;
}
}
}
class _TextTempWidget extends HookConsumerWidget {
final String filePath;