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?> showBatchRename(BuildContext context, List paths, {required bool Function(String) isDir}) => showDialog>( context: context, barrierDismissible: false, builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir), ); class _BatchRenameDialog extends StatefulWidget { final List 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 = >{}; bool _loading = true; bool _running = false; String? _error; @override void initState() { super.initState(); _loadSiblings(); } Future _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 _run(List 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( labelText: tr('이름 틀 (확장자는 그대로)'), helperText: tr('[N] 원래 이름 · [C] 번호 예: Show S01E[C]'), 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( 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('바꿀 글 (비우면 지움)'), 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])), ), ], ); } }