JJ_MKVMaker/lib/ui/batch_rename_dialog.dart

264 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/batch_rename.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과.
/// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null.
/// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다.
Future<List<(String, String)>?> showBatchRename(BuildContext context, List<String> paths, {required bool Function(String) isDir}) =>
showDialog<List<(String, String)>>(
context: context,
barrierDismissible: false,
builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir),
);
class _BatchRenameDialog extends StatefulWidget {
final List<String> paths;
final bool Function(String) isDir;
const _BatchRenameDialog({required this.paths, required this.isDir});
@override
State<_BatchRenameDialog> createState() => _BatchRenameDialogState();
}
class _BatchRenameDialogState extends State<_BatchRenameDialog> {
final _template = TextEditingController(text: '[N]');
final _start = TextEditingController(text: '1');
final _step = TextEditingController(text: '1');
final _digits = TextEditingController(text: '2');
final _find = TextEditingController();
final _replace = TextEditingController();
bool _ignoreCase = true;
String _case = 'keep';
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
final _siblings = <String, Set<String>>{};
bool _loading = true;
bool _running = false;
String? _error;
@override
void initState() {
super.initState();
_loadSiblings();
}
Future<void> _loadSiblings() async {
for (final dir in {for (final x in widget.paths) vDirname(x)}) {
try {
_siblings[dir] = {for (final e in await vList(dir)) vBasename(e.path)};
} catch (_) {
_siblings[dir] = {};
}
}
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
for (final x in [_template, _start, _step, _digits, _find, _replace]) {
x.dispose();
}
super.dispose();
}
RenameRule get _rule => RenameRule(
template: _template.text.isEmpty ? '[N]' : _template.text,
start: int.tryParse(_start.text.trim()) ?? 1,
step: int.tryParse(_step.text.trim()) ?? 1,
digits: (int.tryParse(_digits.text.trim()) ?? 1).clamp(1, 9),
find: _find.text,
replace: _replace.text,
ignoreCase: _ignoreCase,
caseMode: _case,
);
void _insert(String tag) {
final t = _template;
final sel = t.selection.isValid ? t.selection : TextSelection.collapsed(offset: t.text.length);
t.value = TextEditingValue(
text: t.text.replaceRange(sel.start, sel.end, tag),
selection: TextSelection.collapsed(offset: sel.start + tag.length),
);
setState(() {});
}
Future<void> _run(List<RenameRow> rows) async {
setState(() {
_running = true;
_error = null;
});
try {
final done = await runBatchRename([for (final r in rows) (r.path, r.newName)]);
if (mounted) Navigator.pop(context, done);
} on BatchRenameException catch (e) {
if (!mounted) return;
setState(() {
_running = false;
_error = [
trf('"{0}" 의 이름을 바꾸지 못해 멈췄습니다: {1}', [vBasename(e.path), e.error]),
if (e.stuck.isEmpty)
tr('이미 바꾼 것은 모두 원래 이름으로 되돌렸습니다.')
else
trf('원래 이름으로 되돌리지 못한 것: {0}', [e.stuck.map(vBasename).join(', ')]),
].join('\n');
});
// 되돌린 뒤의 실제 이름으로 다시 본다
_siblings.clear();
await _loadSiblings();
}
}
Widget _num(TextEditingController c, String label) => SizedBox(
width: 72,
child: TextField(
controller: c,
keyboardType: const TextInputType.numberWithOptions(signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^-?\d{0,6}'))],
decoration: InputDecoration(labelText: label, isDense: true),
onChanged: (_) => setState(() {}),
),
);
@override
Widget build(BuildContext context) {
final rule = _rule;
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings);
final changed = rows.where((r) => r.changed).length;
final bad = rows.where((r) => r.problem != null).length;
final canRun = !_loading && !_running && bad == 0 && changed > 0;
return AlertDialog(
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
content: SizedBox(
width: 640,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// 이름 틀 + [N] [C] 넣기
Row(children: [
Expanded(
child: TextField(
controller: _template,
enabled: !_running,
decoration: InputDecoration(
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
labelText: tr('이름 틀'),
helperText: tr('[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]'),
helperMaxLines: 3,
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 6),
OutlinedButton(onPressed: _running ? null : () => _insert('[N]'), child: const Text('[N]')),
const SizedBox(width: 4),
OutlinedButton(onPressed: _running ? null : () => _insert('[C]'), child: const Text('[C]')),
]),
const SizedBox(height: 6),
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
_num(_start, tr('번호 시작')),
_num(_step, tr('간격')),
_num(_digits, tr('자릿수')),
DropdownButton<String>(
value: _case,
items: [
DropdownMenuItem(value: 'keep', child: Text(tr('대소문자 그대로'))),
DropdownMenuItem(value: 'lower', child: Text(tr('모두 작게 (abc)'))),
DropdownMenuItem(value: 'upper', child: Text(tr('모두 크게 (ABC)'))),
DropdownMenuItem(value: 'title', child: Text(tr('단어 첫 글자만 크게 (Abc)'))),
],
onChanged: _running ? null : (v) => setState(() => _case = v!),
),
]),
const SizedBox(height: 6),
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
SizedBox(
width: 200,
child: TextField(
controller: _find,
enabled: !_running,
decoration: InputDecoration(labelText: tr('찾을 글'), isDense: true),
onChanged: (_) => setState(() {}),
),
),
SizedBox(
width: 200,
child: TextField(
controller: _replace,
enabled: !_running,
decoration: InputDecoration(labelText: tr('바꿀 글'), hintText: tr('비우면 지움'), isDense: true),
onChanged: (_) => setState(() {}),
),
),
Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(value: _ignoreCase, onChanged: _running ? null : (v) => setState(() => _ignoreCase = v!)),
Text(tr('대소문자 무시')),
]),
]),
const SizedBox(height: 10),
Text(
_loading
? tr('폴더를 읽는 중…')
: bad > 0
? trf('문제 {0}개 - 고치기 전에는 바꾸지 않습니다', [bad])
: trf('바뀌는 것 {0}개', [changed]),
style: TextStyle(fontSize: 12, color: bad > 0 ? JjColors.danger : JjColors.textDim),
),
const SizedBox(height: 4),
// 미리 보기
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: rows.length,
itemBuilder: (_, i) {
final r = rows[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: Text(r.oldName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 6), child: Text('→', style: TextStyle(fontSize: 12))),
Expanded(
child: Text(
r.problem == null ? r.newName : '${r.newName} · ${r.problem}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: r.problem != null ? JjColors.danger : (r.changed ? JjColors.text : JjColors.textDim),
),
),
),
]),
);
},
),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 12, color: JjColors.danger)),
),
]),
),
actions: [
TextButton(onPressed: _running ? null : () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(
onPressed: canRun ? () => _run(rows) : null,
child: _running
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: Text(trf('바꾸기 ({0}개)', [changed])),
),
],
);
}
}