WIP 15 캡처 지적 ①~④: 영어 단수 · 좁은 화면 풀 곳 세로 목록 · 찾기/분석 줄은 시작한 폴더 기준 짧은 경로 · 캡처 DEBUG 띠 끔 (검토 대기)

관리자 지시 (15 캡처 지적, 버그 수정):
① 영어 "1 problems" · "1 names": englishSingular (61) 에 problems · names · paths 를 더하고,
   수와 명사 사이 꾸미는 말 하나도 넘게 ("1 incomplete files" → "1 incomplete file"). 78 영어 글 "until fixed".
② 86 풀 곳: 폭 720 미만이면 칩 대신 세로 라디오 목록 (글이 다 보임). ZIP 묶기의 넣을 곳도 같은 도우미.
③ 79 찾기 결과 · 87 큰 파일 줄: 시작한 폴더 이름부터의 상대 경로 (shortUnder), 길면 가운데 줄임 (앞 … 마지막 두 단계),
   전체 경로는 마우스를 올리거나 그 줄을 길게 누르면 (Tooltip).
④ 캡처 시험 debugShowCheckedModeBanner: false.

내 판단: ① 은 개수별 글을 따로 두지 않고 기존 61 의 단수 처리를 넓힘 (같은 꼴의 다른 글도 함께 고쳐짐).
② 를 ZIP 묶기 창에도 적용 (같은 모양).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 15:57:25 +09:00
parent bb95be3589
commit 0f3133cc68
10 changed files with 113 additions and 21 deletions

View File

@ -1114,7 +1114,7 @@
"문서 미리보기": "Document preview",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
"문자셋 경고": "Charset warning",
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed",
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until fixed",
"묻기": "Ask",
"묻기 (기본)": "Ask (default)",
"물어보지 않기": "Never ask",

View File

@ -55,7 +55,9 @@ void main() {
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
// DEBUG 띠가 오른쪽 위 단추 (79 [모두 선택]) 를 가리지 않게 (15 지적)
child: MaterialApp(
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
));
BuildContext ctx() => navKey.currentContext!;

View File

@ -141,6 +141,27 @@ bool samePath(String a, String b) {
return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y;
}
/// 79 · 87: 찾기 · 분석 결과 줄의 폴더 - 시작한 폴더 이름부터의 상대 경로 ("Videos\Show\S1").
/// 단계가 [keep] + 1 보다 많으면 가운데를 줄인다 ("Videos\…\S1\E01"). 시작한 폴더 밖이면 전체 경로.
/// (15 캡처: 폭 412 에서 절대 경로의 앞부분만 보여 구별에 필요한 아래 폴더가 잘림)
String shortUnder(String root, String dir, {int keep = 2}) {
final parts = <String>[];
var x = dir;
for (var i = 0; i < 256 && !samePath(x, root); i++) {
final up = vDirname(x);
if (up == x) return vDisplay(dir);
parts.insert(0, vBasename(x));
x = up;
}
if (!samePath(x, root)) return vDisplay(dir);
// 드라이브 · 서버 맨 위는 이름이 없어 그대로 (C: · ☁ 서버)
final top = vDirname(root) == root;
parts.insert(0, top ? (isDav(root) ? vDisplay(root) : root.replaceAll(RegExp(r'[\\/]+$'), '')) : vBasename(root));
final sep = isDav(dir) ? '/' : p.separator;
if (parts.length > keep + 1) return [parts.first, '…', ...parts.sublist(parts.length - keep)].join(sep);
return parts.join(sep);
}
/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게)
bool isSameOrInside(String a, String b) {
final dav = davInside(a, b);

View File

@ -39,7 +39,8 @@ String trf(String ko, List<Object?> args) {
}
/// 수가 1 인 영어 복수 명사를 단수로 ("1 videos" · "(1 files)" → "1 video" · "(1 file)"). 11 · 21 · 1.5 는 그대로.
String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${_singularOf[m[2]!]}');
/// 수와 명사 사이의 꾸미는 말 하나도 넘는다 ("1 incomplete files" → "1 incomplete file").
String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${m[2] ?? ''}${_singularOf[m[3]!]}');
const _singularOf = {
'videos': 'video', 'files': 'file', 'items': 'item', 'subtitles': 'subtitle', 'downloads': 'download',
@ -48,6 +49,7 @@ const _singularOf = {
'servers': 'server', 'tracks': 'track', 'languages': 'language', 'models': 'model', 'bookmarks': 'bookmark',
'errors': 'error', 'seconds': 'second', 'minutes': 'minute', 'hours': 'hour', 'days': 'day', 'tasks': 'task',
'copies': 'copy', 'entries': 'entry', 'episodes': 'episode', 'changes': 'change', 'components': 'component',
'names': 'name', 'paths': 'path', 'problems': 'problem',
};
final _one = RegExp('(^|[^\\d.,])1 (${_singularOf.keys.join('|')})\\b');
final _one = RegExp('(^|[^\\d.,])1 ((?!of )[A-Za-z][\\w-]* )?(${_singularOf.keys.join('|')})\\b');

View File

@ -11,6 +11,7 @@ import '../core/archive_ops.dart';
import '../core/file_ops.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'app_actions.dart' show isCompact;
import 'theme.dart';
/// 86: 동봉한 7-Zip. 없으면 null
@ -200,9 +201,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
],
const SizedBox(height: 10),
Wrap(spacing: 6, runSpacing: 4, children: [
for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: dest == d, onSelected: (_) => set(() => dest = d)),
]),
_placeChoice(ctx, choices, dest, (d) => set(() => dest = d)),
Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6),
Row(children: [
@ -286,6 +285,24 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
}
}
/// 86: 풀 곳 · 넣을 곳 고르기. 넓으면 칩, 좁으면 (폭 720 미만) 글이 다 보이게 세로 목록 (15 캡처: 412 에서 칩 글이 잘림)
Widget _placeChoice(BuildContext context, List<(String, String)> choices, String value, void Function(String) onPick) {
if (!isCompact(context)) {
return Wrap(spacing: 6, runSpacing: 4, children: [
for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: value == d, onSelected: (_) => onPick(d)),
]);
}
return RadioGroup<String>(
groupValue: value,
onChanged: (d) {
if (d != null) onPick(d);
},
child: Column(mainAxisSize: MainAxisSize.min, children: [
for (final (d, label) in choices) RadioListTile<String>(dense: true, contentPadding: EdgeInsets.zero, value: d, title: Text(label)),
]),
);
}
/// 86: ZIP 으로 묶기 - 이름 · 압축 정도 (설정으로 기억) · 넣을 곳. 링크 · junction 은 넣지 않는다. 만든 ZIP 경로 (취소 · 실패면 null)
Future<String?> createZipFlow(BuildContext context, AppController c, List<String> sources, {required String dir, String? otherDir}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
@ -329,9 +346,7 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
ChoiceChip(label: Text(label), selected: level == v, onSelected: (_) => set(() => level = v)),
]),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 4, children: [
for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: where == d, onSelected: (_) => set(() => where = d)),
]),
_placeChoice(ctx, choices, where, (d) => set(() => where = d)),
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),

View File

@ -417,15 +417,20 @@ class _SearchResultsPageState extends State<SearchResultsPage> {
onChanged: (_) => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)),
),
title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
// 폴더는 찾기 시작한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 이 줄을 길게 누르면
subtitle: Tooltip(
message: vDisplay(vDirname(e.path)),
triggerMode: TooltipTriggerMode.longPress,
child: Text(
[
vDirname(e.path),
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),

View File

@ -199,7 +199,13 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
const SizedBox(height: 4),
_bar(top == 0 ? 0 : f.size / top),
const SizedBox(height: 2),
Text('${formatSize(f.size)} · ${vDisplay(vDirname(f.path))}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
// 폴더는 분석한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 길게 누르면
Tooltip(
message: vDisplay(vDirname(f.path)),
triggerMode: TooltipTriggerMode.longPress,
child: Text('${formatSize(f.size)} · ${shortUnder(widget.root, vDirname(f.path))}',
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
),
]),
// 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서)
onTap: () => Navigator.pop(context, f.path),

View File

@ -27,8 +27,8 @@ void main() {
}
}
Future<Future<T?> Function(Future<T?> Function(BuildContext))> host<T>(WidgetTester tester) async {
tester.view.physicalSize = const Size(1000, 900);
Future<Future<T?> Function(Future<T?> Function(BuildContext))> host<T>(WidgetTester tester, {Size size = const Size(1000, 900)}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final key = GlobalKey<NavigatorState>();
@ -81,6 +81,29 @@ void main() {
}
});
testWidgets('풀 곳: 좁은 화면 (412) 은 글이 다 보이는 세로 목록 · 고른 곳에 풂 (15 캡처: 칩 글 잘림)', (tester) async {
final zip = p.join(tmp.path, 'sample.zip');
File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('a.txt', 'a'))));
final other = Directory(p.join(tmp.path, 'other'))..createSync();
final run = await host<String>(tester, size: const Size(412, 900));
String? out;
var finished = false;
run((ctx) => extractArchiveFlow(ctx, c, zip, otherDir: other.path)).then((v) {
out = v;
finished = true;
});
await wait(tester, () => find.text('풀기').evaluate().isNotEmpty);
expect(find.byType(ChoiceChip), findsNothing);
expect(find.byType(RadioListTile<String>), findsNWidgets(3));
expect(find.text('압축 옆 새 폴더 "sample"'), findsOneWidget);
await tester.tap(find.text('다른 창 폴더'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '풀기'));
await wait(tester, () => finished);
expect(out, other.path);
expect(File(p.join(other.path, 'a.txt')).existsSync(), isTrue);
});
testWidgets('ZIP 으로 묶기: 이름 · 압축 정도 (기억) → 만든 ZIP', (tester) async {
final src = Directory(p.join(tmp.path, 'photos'))..createSync();
File(p.join(src.path, 'a.txt')).writeAsStringSync('A');

View File

@ -23,6 +23,20 @@ void main() {
expect(c.summary, contains('1.0GB'));
});
test('결과 줄의 폴더: 시작한 폴더 이름부터 · 길면 가운데 줄임 · 밖이면 전체 (15 캡처)', () {
final root = p.join(Directory.systemTemp.path, 'Videos');
String j(List<String> xs) => xs.join(p.separator);
expect(shortUnder(root, root), 'Videos');
expect(shortUnder(root, p.join(root, 'Show')), j(['Videos', 'Show']));
expect(shortUnder(root, p.join(root, 'Show', 'S1')), j(['Videos', 'Show', 'S1']));
expect(shortUnder(root, p.join(root, 'Show', 'S1', 'E01', 'sub')), j(['Videos', '…', 'E01', 'sub']));
final out = p.join(Directory.systemTemp.path, 'Other', 'x');
expect(shortUnder(root, out), out);
final drive = p.rootPrefix(Directory.systemTemp.path);
expect(shortUnder(drive, p.join(drive, 'A')), startsWith(drive.replaceAll(RegExp(r'[\\/]+$'), '')));
expect(shortUnder(drive, p.join(drive, 'A')), isNot(contains('${p.separator}${p.separator}')));
});
test('조건: N일 안 · 확장자 직접 · 조건 없음', () {
expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 3), now: now), isTrue);
expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 9), now: now), isFalse);

View File

@ -18,6 +18,10 @@ void main() {
expect(englishSingular('1 copies · 1 entries'), '1 copy · 1 entry');
expect(englishSingular('11 videos · 21 files · 1.1 videos · 0,1 items'), '11 videos · 21 files · 1.1 videos · 0,1 items');
expect(englishSingular('2 videos'), '2 videos');
// 꾸미는 말 하나 · 새 명사 (15 캡처 지적: "1 problems" · "1 names")
expect(englishSingular('1 incomplete files · the 1 checked videos'), '1 incomplete file · the 1 checked video');
expect(englishSingular('1 problems · 1 names · Copied 1 paths'), '1 problem · 1 name · Copied 1 path');
expect(englishSingular('2 incomplete files · 1 of videos · 1 big old files'), '2 incomplete files · 1 of videos · 1 big old files');
});
test('영어 화면에서 trf 가 단수를 쓴다 (한국어 · 일본어는 그대로)', () async {