471 lines
18 KiB
Dart
471 lines
18 KiB
Dart
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<String> 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<SearchConditions?> askSearchConditions(BuildContext context, String where, SearchConditions last) =>
|
|
showDialog<SearchConditions>(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<String>(
|
|
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<String>(
|
|
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<String> paths;
|
|
const SearchAction(this.kind, this.paths);
|
|
}
|
|
|
|
/// 79: 찾기 결과를 목록으로 - 고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생 (화면은 그대로).
|
|
/// 2000개마다 멈추고 [더 보기]
|
|
class SearchResultsPage extends StatefulWidget {
|
|
final String root;
|
|
final SearchConditions cond;
|
|
final bool showHidden;
|
|
|
|
/// 열기 · 재생 (탐색기와 같게). 결과 화면은 닫지 않는다
|
|
final Future<void> Function(FileEntry e) onOpen;
|
|
|
|
/// 복사 · 이동할 곳 (다른 창의 지금 폴더, 없으면 null - 복사 · 이동은 담아 두기)
|
|
final String? otherDir;
|
|
|
|
/// 한 번에 보여 줄 개수 (멈추고 [더 보기])
|
|
final int chunk;
|
|
|
|
const SearchResultsPage({
|
|
super.key,
|
|
required this.root,
|
|
required this.cond,
|
|
required this.showHidden,
|
|
required this.onOpen,
|
|
this.otherDir,
|
|
this.chunk = 2000,
|
|
});
|
|
|
|
@override
|
|
State<SearchResultsPage> createState() => _SearchResultsPageState();
|
|
}
|
|
|
|
class _SearchResultsPageState extends State<SearchResultsPage> {
|
|
final _found = <FileEntry>[];
|
|
final _marked = <String>{};
|
|
final _skipped = <(String, String)>[];
|
|
StreamSubscription<FileEntry>? _sub;
|
|
bool _done = false;
|
|
bool _paused = false;
|
|
late int _until = widget.chunk;
|
|
|
|
/// 한도를 넘은 첫 항목 - 이것이 왔을 때만 멈추고 [더 보기] (딱 한도만큼이면 멈췄다고 하지 않는다)
|
|
FileEntry? _held;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
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();
|
|
}
|
|
|
|
int get _markedBytes => _found.where((e) => _marked.contains(e.path) && !e.isDir).fold(0, (a, e) => a + e.size);
|
|
|
|
void _act(String kind) => Navigator.pop(context, SearchAction(kind, [for (final e in _found) if (_marked.contains(e.path)) e.path]));
|
|
|
|
@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))),
|
|
),
|
|
if (_found.isNotEmpty)
|
|
IconButton(
|
|
tooltip: allMarked ? tr('모두 풀기') : tr('모두 고르기'),
|
|
icon: Icon(allMarked ? Icons.deselect : Icons.select_all),
|
|
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: Text(
|
|
[
|
|
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])),
|
|
),
|
|
// 고르는 중이면 누르기 = 고르기, 아니면 파일은 열기 · 재생 (화면은 그대로), 폴더는 그 폴더로
|
|
onTap: any
|
|
? () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path))
|
|
: e.isDir
|
|
? () => Navigator.pop(context, SearchAction('reveal', [e.path]))
|
|
: () => widget.onOpen(e),
|
|
onLongPress: () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
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(trf('{0}개 · {1}', [_marked.length, formatSize(_markedBytes)]), 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)}';
|
|
}
|
|
}
|