import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../core/audio_tags.dart'; import '../core/batch_rename.dart'; import '../core/photo_date.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_field.dart'; /// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과. /// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null. /// 실패하면 이미 바꾼 것은 되돌리고 창에 이유를 보인다. /// X-plore 비교 i: [initial] 은 마지막에 쓴 규칙 (창이 그 값으로 열림), [onRun] 은 바꾸기를 누를 때의 규칙 (기억하려고) Future?> showBatchRename(BuildContext context, List paths, {required bool Function(String) isDir, RenameRule initial = const RenameRule(), void Function(RenameRule rule)? onRun}) => showDialog>( context: context, barrierDismissible: false, builder: (_) => _BatchRenameDialog(paths: paths, isDir: isDir, initial: initial, onRun: onRun), ); class _BatchRenameDialog extends StatefulWidget { final List paths; final bool Function(String) isDir; final RenameRule initial; final void Function(RenameRule rule)? onRun; const _BatchRenameDialog({required this.paths, required this.isDir, required this.initial, this.onRun}); @override State<_BatchRenameDialog> createState() => _BatchRenameDialogState(); } class _BatchRenameDialogState extends State<_BatchRenameDialog> { late final _template = TextEditingController(text: widget.initial.template); late final _start = TextEditingController(text: '${widget.initial.start}'); late final _step = TextEditingController(text: '${widget.initial.step}'); late final _digits = TextEditingController(text: '${widget.initial.digits}'); late final _find = TextEditingController(text: widget.initial.find); late final _replace = TextEditingController(text: widget.initial.replace); late bool _ignoreCase = widget.initial.ignoreCase; late String _case = widget.initial.caseMode; late String _dateSource = widget.initial.dateSource; /// X-plore 비교 i: 항목마다 바뀐 때 (목록에서) · 찍은 때 (사진 Exif, 틀에 [D] · [T] 가 있을 때 읽음) final _modified = {}; final _taken = {}; bool _takenLoading = false; /// X-plore 비교 5-1 ③ (관리자 지시 10/21): 음악 태그 (틀에 [Title] · [Artist] … 가 있을 때 한 번만 읽음, 이 기기의 파일만) final _tags = {}; bool _tagsLoading = false; Future _loadTags() async { if (_tagsLoading || _tags.isNotEmpty) return; _tagsLoading = true; for (final x in widget.paths) { _tags[x] = isDav(x) ? const AudioTags() : await readAudioTags(x); } if (mounted) setState(() => _tagsLoading = false); } /// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음) 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 { final list = await vList(dir); _siblings[dir] = {for (final e in list) vBasename(e.path)}; for (final e in list) { if (widget.paths.contains(e.path)) _modified[e.path] = e.modified; } } catch (_) { _siblings[dir] = {}; } } if (mounted) setState(() => _loading = false); } /// 사진의 찍은 때를 한 번만 읽는다 (틀에 [D] · [T] 를 넣고 "찍은 때" 일 때) Future _loadTaken() async { if (_takenLoading || _taken.isNotEmpty) return; _takenLoading = true; for (final x in widget.paths) { _taken[x] = await photoTakenAt(x); } if (mounted) setState(() => _takenLoading = false); } Map get _dates => { for (final x in widget.paths) x: ?((_dateSource == 'photo' ? _taken[x] : null) ?? _modified[x]), }; @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, dateSource: _dateSource, ); 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; }); widget.onRun?.call(_rule); 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; if (rule.usesDate && _dateSource == 'photo') _loadTaken(); if (rule.usesTags) _loadTags(); final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates, tags: _tags); final changed = rows.where((r) => r.changed).length; final bad = rows.where((r) => r.problem != null).length; final canRun = !_loading && !_takenLoading && !_tagsLoading && !_running && bad == 0 && changed > 0; return AlertDialog( title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])), // 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게 content: SizedBox( width: 640, child: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 이름 틀 + [N] [C] 넣기 Row(children: [ Expanded( // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 (넣는 법이 길어 4줄까지) child: JjField( controller: _template, enabled: !_running, label: tr('이름 틀'), helper: tr('[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]'), helperLines: 4, 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]')), ]), // X-plore 비교 i: 날짜 · 시각 넣기와 어느 날짜를 쓸지 Row(children: [ OutlinedButton(onPressed: _running ? null : () => _insert('[D]'), child: const Text('[D]')), const SizedBox(width: 4), OutlinedButton(onPressed: _running ? null : () => _insert('[T]'), child: const Text('[T]')), const SizedBox(width: 8), // 좁은 화면 (412) 에서 긴 항목이 창을 넘었다 - 남은 폭 안에서 줄임 Expanded( child: DropdownButton( isExpanded: true, value: _dateSource, // 412 캡처 (10/18): 고른 뒤 상자에는 짧게 (펼친 목록은 긴 설명 그대로) selectedItemBuilder: (_) => [ Text(tr('날짜: 찍은 때'), maxLines: 1, overflow: TextOverflow.ellipsis), Text(tr('날짜: 바뀐 때'), maxLines: 1, overflow: TextOverflow.ellipsis), ], items: [ DropdownMenuItem(value: 'photo', child: Text(tr('날짜: 찍은 때 (사진, 없으면 바뀐 때)'), overflow: TextOverflow.ellipsis)), DropdownMenuItem(value: 'modified', child: Text(tr('날짜: 바뀐 때'), overflow: TextOverflow.ellipsis)), ], onChanged: _running ? null : (v) => setState(() => _dateSource = v!), ), ), // X-plore 비교 5-1 ③: 음악 태그 넣기 (이 기기의 MP3 · FLAC · M4A) PopupMenuButton( enabled: !_running, tooltip: tr('음악 태그 넣기'), onSelected: _insert, itemBuilder: (_) => [ for (final (t, label) in [ ('[Artist]', tr('가수')), ('[Title]', tr('제목')), ('[Album]', tr('앨범')), ('[Track]', tr('트랙 번호')), ('[Year]', tr('해')), ]) PopupMenuItem(value: t, child: Text('$t $label')), ], // 412 · 큰 글자에서 이 줄이 넘쳐 (253 시험) 글 대신 작은 아이콘 child: Padding( padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Icon(Icons.music_note_outlined, size: 20, color: JjColors.accent), ), ), ]), const SizedBox(height: 6), Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [ _num(_start, tr('번호 시작')), _num(_step, tr('간격')), _num(_digits, tr('자릿수')), ]), // 대소문자 고르기는 한 줄 전체 (가장 긴 항목 폭이 좁은 화면 · 큰 글자에서 창을 넘었다 - 253 시험) DropdownButton( isExpanded: true, value: _case, items: [ DropdownMenuItem(value: 'keep', child: Text(tr('대소문자 그대로'), overflow: TextOverflow.ellipsis)), DropdownMenuItem(value: 'lower', child: Text(tr('모두 작게 (abc)'), overflow: TextOverflow.ellipsis)), DropdownMenuItem(value: 'upper', child: Text(tr('모두 크게 (ABC)'), overflow: TextOverflow.ellipsis)), DropdownMenuItem(value: 'title', child: Text(tr('단어 첫 글자만 크게 (Abc)'), overflow: TextOverflow.ellipsis)), ], 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!)), Flexible(child: 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), // 미리 보기 (창 전체가 스크롤 - 낮은 화면 · 큰 글자에서 아래가 넘쳤다, 253 시험) 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: 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: 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])), ), ], ); } }