import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import '../core/file_ops.dart'; import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/subtitle_detector.dart' show audioExtensions, subtitleExtensions, videoExtensions; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; /// 79: 찾기 조건 - 이름 (없어도 됨) · 크기 · 바뀐 날 · 종류 ("1GB 넘는 30일 지난 영상") class SearchConditions { /// 이름 (비우면 이름은 보지 않음). * · ? 를 쓸 수 있다 final String name; /// 이 크기 이상 (바이트, null = 보지 않음) final int? minBytes; /// 이 날 수보다 오래전에 바뀐 것만 (null = 보지 않음) final int? olderDays; /// 이 날 수 안에 바뀐 것만 (null = 보지 않음) final int? newerDays; /// 'all' · 'video' · 'audio' · 'subtitle' · 'image' · 'custom' ([exts]) final String kind; /// 'custom' 일 때 확장자 ("mkv, mp4") final String exts; const SearchConditions({this.name = '', this.minBytes, this.olderDays, this.newerDays, this.kind = 'all', this.exts = ''}); /// 이름 말고 다른 조건이 있는지 (있으면 파일만 찾는다 - 폴더에는 크기 · 종류가 없다) bool get hasFileConditions => minBytes != null || olderDays != null || newerDays != null || kind != 'all'; bool get isEmpty => name.trim().isEmpty && !hasFileConditions; List get _kindExts => switch (kind) { 'video' => videoExtensions, 'audio' => audioExtensions, 'subtitle' => subtitleExtensions, 'image' => defaultImageExtensions, 'custom' => [ for (final x in exts.split(RegExp(r'[,;\s]+'))) if (x.trim().isNotEmpty) x.trim().toLowerCase().replaceFirst(RegExp(r'^\*?\.'), ''), ], _ => const [], }; /// 이름 말고 다른 조건에 맞는지 ([now] 는 시험에서 바꿔 끼움) bool accepts(FileEntry e, {DateTime? now}) { if (!hasFileConditions) return true; if (e.isDir) return false; if (minBytes != null && e.size < minBytes!) return false; final t = now ?? DateTime.now(); if (olderDays != null && e.modified.isAfter(t.subtract(Duration(days: olderDays!)))) return false; if (newerDays != null && e.modified.isBefore(t.subtract(Duration(days: newerDays!)))) return false; if (kind != 'all' && !_kindExts.contains(e.ext)) return false; return true; } /// 화면에 보일 요약: "이름 *.mkv · 1GB 이상 · 30일 지남 · 동영상" String get summary => [ if (name.trim().isNotEmpty) '"${name.trim()}"', if (minBytes != null) trf('{0} 이상', [formatSize(minBytes!)]), if (olderDays != null) trf('{0}일 지남', [olderDays!]), if (newerDays != null) trf('{0}일 안', [newerDays!]), if (kind != 'all') kindLabel(kind, exts), ].join(' · '); static String kindLabel(String k, [String exts = '']) => switch (k) { 'video' => tr('동영상'), 'audio' => tr('음악'), 'subtitle' => tr('자막'), 'image' => tr('그림'), 'custom' => exts.trim().isEmpty ? tr('확장자 직접') : exts.trim(), _ => tr('모두'), }; } /// 79: 찾기 조건 창. 취소하면 null Future askSearchConditions(BuildContext context, String where, SearchConditions last) => showDialog(context: context, builder: (_) => _ConditionsDialog(where: where, last: last)); class _ConditionsDialog extends StatefulWidget { final String where; final SearchConditions last; const _ConditionsDialog({required this.where, required this.last}); @override State<_ConditionsDialog> createState() => _ConditionsDialogState(); } class _ConditionsDialogState extends State<_ConditionsDialog> { late final _name = TextEditingController(text: widget.last.name); late final _size = TextEditingController(text: _sizeText(widget.last.minBytes)); late final _days = TextEditingController(text: '${widget.last.olderDays ?? widget.last.newerDays ?? ''}'); late final _exts = TextEditingController(text: widget.last.exts); late String _unit = (widget.last.minBytes ?? 0) >= _gb && (widget.last.minBytes! % _gb == 0) ? 'GB' : 'MB'; late String _when = widget.last.olderDays != null ? 'older' : (widget.last.newerDays != null ? 'newer' : 'any'); late String _kind = widget.last.kind; static const _mb = 1024 * 1024; static const _gb = 1024 * _mb; static String _sizeText(int? b) { if (b == null) return ''; if (b >= _gb && b % _gb == 0) return '${b ~/ _gb}'; return '${(b / _mb).round()}'; } @override void dispose() { _name.dispose(); _size.dispose(); _days.dispose(); _exts.dispose(); super.dispose(); } SearchConditions get _value { final size = double.tryParse(_size.text.trim()); final days = int.tryParse(_days.text.trim()); return SearchConditions( name: _name.text.trim(), minBytes: size == null || size <= 0 ? null : (size * (_unit == 'GB' ? _gb : _mb)).round(), olderDays: _when == 'older' && days != null && days > 0 ? days : null, newerDays: _when == 'newer' && days != null && days > 0 ? days : null, kind: _kind, exts: _exts.text.trim(), ); } void _submit() { final v = _value; if (v.isEmpty) return; Navigator.pop(context, v); } @override Widget build(BuildContext context) { final v = _value; return AlertDialog( scrollable: true, title: Text(trf('찾기: {0}', [widget.where])), content: SizedBox( width: 460, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _name, autofocus: true, decoration: InputDecoration( labelText: tr('이름 (비워도 됨)'), hintText: tr('예: S01E · *.srt'), isDense: true, ), onChanged: (_) => setState(() {}), onSubmitted: (_) => _submit(), ), const SizedBox(height: 12), // 크기: 이 크기 이상 Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ SizedBox( width: 110, child: TextField( controller: _size, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration(labelText: tr('크기 이상'), isDense: true), onChanged: (_) => setState(() {}), ), ), DropdownButton( value: _unit, items: const [ DropdownMenuItem(value: 'MB', child: Text('MB')), DropdownMenuItem(value: 'GB', child: Text('GB')), ], onChanged: (x) => setState(() => _unit = x!), ), ]), const SizedBox(height: 8), // 바뀐 날: 아무 때 · N일 지남 · N일 안 Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ DropdownButton( value: _when, items: [ DropdownMenuItem(value: 'any', child: Text(tr('바뀐 날: 아무 때'))), DropdownMenuItem(value: 'older', child: Text(tr('바뀐 지 … 일 지남'))), DropdownMenuItem(value: 'newer', child: Text(tr('바뀐 지 … 일 안'))), ], onChanged: (x) => setState(() => _when = x!), ), if (_when != 'any') SizedBox( width: 80, child: TextField( controller: _days, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: tr('일'), isDense: true), onChanged: (_) => setState(() {}), ), ), ]), const SizedBox(height: 8), // 종류 Wrap(spacing: 6, runSpacing: 4, children: [ for (final k in const ['all', 'video', 'audio', 'subtitle', 'image', 'custom']) ChoiceChip( label: Text(SearchConditions.kindLabel(k)), selected: _kind == k, onSelected: (_) => setState(() => _kind = k), ), ]), if (_kind == 'custom') TextField( controller: _exts, decoration: InputDecoration(labelText: tr('확장자 (쉼표로)'), hintText: 'mkv, mp4', isDense: true), onChanged: (_) => setState(() {}), ), if (v.hasFileConditions) Padding( padding: const EdgeInsets.only(top: 8), child: Text(tr('크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton(onPressed: v.isEmpty ? null : _submit, child: Text(tr('찾기'))), ], ); } } /// 79: 찾기 결과 화면에서 고른 일 (화면을 닫고 탐색기가 한다 - 진행 패널이 보이게) class SearchAction { /// 'reveal' (그 자리로) · 'copy' · 'move' · 'delete' final String kind; final List paths; /// 79-a: 그때까지 찾은 것 · 다 찾았는지 (탐색기의 [찾기 결과로 돌아가기] 에 쓴다) final List found; final bool done; const SearchAction(this.kind, this.paths, {this.found = const [], this.done = true}); } /// 79: 찾기 결과를 목록으로 - 고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생 (화면은 그대로). /// 2000개마다 멈추고 [더 보기] class SearchResultsPage extends StatefulWidget { final String root; final SearchConditions cond; final bool showHidden; /// 열기 · 재생 (탐색기와 같게). 결과 화면은 닫지 않는다 final Future Function(FileEntry e) onOpen; /// 복사 · 이동할 곳 (다른 창의 지금 폴더, 없으면 null - 복사 · 이동은 담아 두기) final String? otherDir; /// 한 번에 보여 줄 개수 (멈추고 [더 보기]) final int chunk; /// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고 이 목록을 보인다 ([initialDone] 이 거짓이면 그때 다 찾지 못한 것) final List? initial; final bool initialDone; const SearchResultsPage({ super.key, required this.root, required this.cond, required this.showHidden, required this.onOpen, this.otherDir, this.chunk = 2000, this.initial, this.initialDone = true, }); @override State createState() => _SearchResultsPageState(); } class _SearchResultsPageState extends State { final _found = []; final _marked = {}; final _skipped = <(String, String)>[]; StreamSubscription? _sub; bool _done = false; bool _paused = false; late int _until = widget.chunk; /// 한도를 넘은 첫 항목 - 이것이 왔을 때만 멈추고 [더 보기] (딱 한도만큼이면 멈췄다고 하지 않는다) FileEntry? _held; @override void initState() { super.initState(); if (widget.initial != null) { _found.addAll(widget.initial!); _done = true; return; } final c = widget.cond; _sub = searchFiles(widget.root, c.name, showHidden: widget.showHidden, limit: 1 << 40, accept: c.hasFileConditions ? c.accepts : null, onSkipped: (dir, e) { if (mounted) setState(() => _skipped.add((dir, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'))); }).listen( (e) { if (!mounted) return; // 한 번에 [chunk] 개 - 그다음 것이 오면 붙잡아 두고 멈춘 뒤 [더 보기] 를 기다린다 if (_found.length >= _until) { _held = e; _sub?.pause(); setState(() => _paused = true); return; } setState(() => _found.add(e)); }, onDone: () { if (mounted) setState(() => _done = true); }, onError: (_) {}, ); } @override void dispose() { _sub?.cancel(); super.dispose(); } void _more() { setState(() { _until += widget.chunk; _paused = false; if (_held != null) _found.add(_held!); _held = null; }); _sub?.resume(); } /// 79-c: 고른 것 중 바깥 것만 (고른 폴더 안의 파일은 그 폴더와 함께 한 번만 처리) List get _targets { final keep = outermostPaths([for (final e in _found) if (_marked.contains(e.path)) e.path]).toSet(); return [for (final e in _found) if (keep.contains(e.path)) e]; } /// 79-b: 아래 줄 - "파일 n개 · 크기", 폴더가 있으면 "폴더 m개 (크기 미포함)" 를 따로 (합계가 실제보다 작아 보이지 않게) String _summary(List t) { final files = t.where((e) => !e.isDir).toList(); final dirs = t.length - files.length; return [ if (files.isNotEmpty) trf('파일 {0}개 · {1}', [files.length, formatSize(files.fold(0, (a, e) => a + e.size))]), if (dirs > 0) trf('폴더 {0}개 (크기 미포함)', [dirs]), ].join(' / '); } void _act(String kind) => Navigator.pop( context, SearchAction(kind, [for (final e in _targets) e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)); @override Widget build(BuildContext context) { final compact = isCompact(context); final any = _marked.isNotEmpty; final allMarked = _found.isNotEmpty && _marked.length == _found.length; return Scaffold( appBar: AppBar( title: Text(trf('찾기 결과 · {0}개', [_found.length]), maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ if (!_done && !_paused) const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), ), // 79-d: 찾는 중에는 "지금까지 찾은 n개 모두" (뒤에 나올 것은 고르지 않는다는 것이 보이게), 다 찾으면 "모두" if (_found.isNotEmpty) TextButton.icon( icon: Icon(allMarked ? Icons.deselect : Icons.select_all, size: 18), label: Text(allMarked ? tr('모두 풀기') : _done ? tr('모두') : trf('지금까지 찾은 {0}개 모두', [_found.length])), onPressed: () => setState(() => allMarked ? _marked.clear() : _marked.addAll(_found.map((e) => e.path))), ), ], ), body: Column(children: [ // 어디서 무엇을 Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: Align( alignment: Alignment.centerLeft, child: Text('${widget.cond.summary} · ${widget.root}', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), ), Expanded( child: _found.isEmpty && _done ? Center(child: Text(tr('찾은 것이 없습니다.'))) : ListView.builder( itemCount: _found.length, itemBuilder: (_, i) { final e = _found[i]; final on = _marked.contains(e.path); return ListTile( dense: true, selected: on, leading: Checkbox( value: on, onChanged: (_) => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), ), title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), // 폴더는 찾기 시작한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 이 줄을 길게 누르면 subtitle: Tooltip( message: vDisplay(vDirname(e.path)), triggerMode: TooltipTriggerMode.longPress, child: Text( [ shortUnder(widget.root, vDirname(e.path)), if (!e.isDir) formatSize(e.size), if (e.modified.year > 1) _date(e.modified), ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), trailing: IconButton( tooltip: tr('그 자리로'), icon: const Icon(Icons.drive_file_move_outline, size: 20), onPressed: () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)), ), // 고르는 중이면 누르기 = 고르기, 아니면 파일은 열기 · 재생 (화면은 그대로), 폴더는 그 폴더로 onTap: any ? () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)) : e.isDir ? () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)) : () => widget.onOpen(e), onLongPress: () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), ); }, ), ), if (widget.initial != null && !widget.initialDone) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Text(tr('처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), if (_paused) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(trf('{0}개에서 멈췄습니다', [_found.length]), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), OutlinedButton(onPressed: _more, child: Text(trf('{0}개 더 보기', [widget.chunk]))), ]), ), if (_skipped.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ExpansionTile( tilePadding: EdgeInsets.zero, dense: true, title: Text(trf('읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다', [_skipped.length]), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), children: [ for (final (dir, why) in _skipped.take(50)) Align( alignment: Alignment.centerLeft, child: Text('$dir · $why', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), ), ], ), ), // 고른 것으로 할 일 (고른 것만 대상 - 154 · 155) if (any) SafeArea( top: false, child: Container( decoration: const BoxDecoration(border: Border(top: BorderSide(color: JjColors.border))), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Wrap(spacing: 4, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(_summary(_targets), style: const TextStyle(fontSize: 12)), TextButton.icon( onPressed: () => _act('copy'), icon: const Icon(Icons.copy, size: 18), label: Text(widget.otherDir == null || compact ? tr('복사') : trf('복사 → {0}', [vBasename(widget.otherDir!)])), ), TextButton.icon( onPressed: () => _act('move'), icon: const Icon(Icons.drive_file_move, size: 18), label: Text(widget.otherDir == null || compact ? tr('이동') : trf('이동 → {0}', [vBasename(widget.otherDir!)])), ), TextButton.icon( onPressed: () => _act('delete'), icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.danger), label: Text(tr('지우기'), style: const TextStyle(color: JjColors.danger)), ), ]), ), ), ]), ); } static String _date(DateTime d) { String two(int x) => x.toString().padLeft(2, '0'); return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}'; } }