diff --git a/integration_test/titlebar_places_shot_test.dart b/integration_test/titlebar_places_shot_test.dart new file mode 100644 index 0000000..303d3ce --- /dev/null +++ b/integration_test/titlebar_places_shot_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/ui/ai_upscale/upscale_compare.dart'; +import 'package:jj_mkvmaker/ui/android_file_browser.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// W JjTitleBar (관리자 10/24): 위 막대를 모을 때 캡처가 없던 곳을 찍는다 - AI 올리기 비교 (원본 · 올린 것 고르기) · +/// 앱 안 폴더 고르기 (새 폴더 · 고르기 단추). 둘 다 넓은 단추가 있어 좁으면 제목 아래 줄로 간다. 412 · 960 × ko · ja (JJ_SHOT_DIR) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_titlebar'); + + for (final (w, h) in [(412, 900), (960, 700)]) { + for (final lang in ['ko', 'ja']) { + testWidgets('위 막대 자리 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testFixedTempDir('jj_titlebar_fixed'); + final a = p.join(tmp.path, 'a.png'), b = p.join(tmp.path, 'b.png'); + File(a).writeAsBytesSync(img.encodePng(img.Image(width: 4, height: 4))); + File(b).writeAsBytesSync(img.encodePng(img.Image(width: 16, height: 16))); + Directory(p.join(tmp.path, '영상')).createSync(); + Directory(p.join(tmp.path, '사진')).createSync(); + pinTestTimes(tmp); + final key = GlobalKey(); + final nav = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: key, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: nav, home: const Scaffold(body: SizedBox.expand())), + )); + + Future settle() async { + for (var i = 0; i < 8; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future shot(String name) async { + expect(t.takeException(), isNull, reason: '$name 넘침'); + final r = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final im = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => im!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'titlebar_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // 1. AI 올리기 비교 + final compare = nav.currentState!.push( + MaterialPageRoute(builder: (_) => UpscaleCompareView(original: a, upscaled: b, title: 'IMG_0001 (4배) - 바다에서 찍은 사진.png'))); + await settle(); + await shot('upscale_compare'); + nav.currentState!.pop(false); + await settle(); + await compare; + + // 2. 앱 안 폴더 고르기 + final folder = showAndroidFolderBrowser(nav.currentContext!, title: '저장할 폴더', initialDirectory: tmp.path); + await settle(); + await shot('folder_browser'); + nav.currentState!.pop(); + await settle(); + await folder; + }); + } + } +} diff --git a/integration_test/windows_vault_shot_test.dart b/integration_test/windows_vault_shot_test.dart index d35c965..91a4654 100644 --- a/integration_test/windows_vault_shot_test.dart +++ b/integration_test/windows_vault_shot_test.dart @@ -14,6 +14,8 @@ import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:jj_mkvmaker/ui/vault_page.dart'; import 'package:path/path.dart' as p; +import 'support/temp_dir.dart'; + /// 암호 보관함 화면 (만들기 · 목록 (폴더 · 파일)) 을 PNG 로 (JJ_SHOT_DIR, JJ_SHOT_THEME=light 면 밝은 화면). /// 보관함 · 시험 파일은 임시 폴더에만 만들고 끝나면 지운다 void main() { @@ -23,8 +25,8 @@ void main() { testWidgets('보관함 화면 그림', (t) async { final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_vault'); Directory(out).createSync(recursive: true); - final tmp = Directory.systemTemp.createTempSync('jj_vaultshot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + // 관리자 지시 (10/24): 폴더 이름을 실행마다 같게 (캡처의 "자리:" 줄이 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_vaultshot_fixed'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.vault.enabled = true; final key = GlobalKey(); @@ -53,8 +55,9 @@ void main() { final v = await t.runAsync(() => Vault.create(dir, 'pw', iterations: 1000)); final f = File(p.join(tmp.path, '사진.jpg'))..writeAsBytesSync(List.filled(2048, 7)); await t.runAsync(() async { - await v!.add(f.path); - await v.add(f.path, folder: '여행'); + // 넣은 날을 정해 둔다 (목록의 날짜가 실행한 날에 따라 바뀌지 않게 - 관리자 10/24) + await v!.add(f.path, added: DateTime(2026, 10, 22, 9)); + await v.add(f.path, folder: '여행', added: DateTime(2026, 10, 22, 9)); await v.mkdir('빈 폴더'); }); for (final (name, size) in [('open_412', const Size(412, 800)), ('open_960', const Size(960, 600))]) { diff --git a/integration_test/windows_wifi_shot_test.dart b/integration_test/windows_wifi_shot_test.dart index 49a503c..d435d73 100644 --- a/integration_test/windows_wifi_shot_test.dart +++ b/integration_test/windows_wifi_shot_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -37,13 +38,14 @@ void main() { addTearDown(() async { await WifiShare.instance?.stop(); }); - final tmp = testTempDir('jj_wifishot_'); + // 관리자 지시 (10/24): 폴더 이름 · 접속 코드 (QR 에도 들어감) 를 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_wifishot_fixed'); final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( key: shotKey, - child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path, net: LoopbackNet())), + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path, net: LoopbackNet(), random: Random(7))), )); Future settle([int n = 8]) async { for (var i = 0; i < n; i++) { @@ -70,6 +72,9 @@ void main() { await settle(); await shot('on'); await t.scrollUntilVisible(find.textContaining(RegExp(r'\(0\)')), 200, scrollable: find.byType(Scrollable).first); + // 스크롤 막대가 사라지는 중간에 찍히면 실행마다 달라 (관리자 10/24) - 다 사라진 뒤에 찍는다 + await t.pump(const Duration(seconds: 3)); + await t.pump(const Duration(seconds: 1)); await shot('on_log'); }); } diff --git a/lib/core/vault_store.dart b/lib/core/vault_store.dart index 7c599d7..870f7d0 100644 --- a/lib/core/vault_store.dart +++ b/lib/core/vault_store.dart @@ -348,9 +348,9 @@ class Vault { } /// [src] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)". - /// [onProgress] (넣은 바이트, 전체) + /// [onProgress] (넣은 바이트, 전체). [added] 넣은 때 (처음 값 = 지금 - 목록의 날짜) Future add(String src, - {String? name, String folder = '', int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress}) async { + {String? name, String folder = '', int chunk = VaultCrypto.defaultChunk, void Function(int, int)? onProgress, DateTime? added}) async { final f = cleanFolder(folder); if (f == null) throw ArgumentError.value(folder, 'folder'); final want = name ?? p.basename(src); @@ -386,7 +386,7 @@ class Vault { } await w.close(); await tmp.rename(out.path); - final e = VaultEntry(blob, freeName(f, want), len, DateTime.now(), folder: f, id: _hex(id)); + final e = VaultEntry(blob, freeName(f, want), len, added ?? DateTime.now(), folder: f, id: _hex(id)); _entries.add(e); try { await _saveIndex(); diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 1e48b2c..7160ee2 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -21,6 +21,7 @@ import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; import 'notice_band.dart'; import 'secret_field.dart'; import 'jj_field.dart'; +import 'jj_title_bar.dart'; /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) List aiMissingFiles(AiStore store) => [ @@ -33,10 +34,11 @@ int aiMissingSize(AiStore store) => aiMissingFiles(store).fold(0, (a, f) => /// AI 그림 화면의 [받기 화면으로] · 설정 버튼: AI 설정 (모델 받기 · 처리 장치 · 서비스) 을 따로 연다 Future openAiModels(BuildContext context, AppController c) => Navigator.of(context).push(MaterialPageRoute( builder: (ctx) => Scaffold( - appBar: AppBar( + appBar: jjTitleBar( + ctx, // 140: 뒤로 버튼 이름도 화면 언어로 leading: IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.maybePop(ctx)), - title: Text(tr('AI 그림 · 해상도 올리기')), + title: tr('AI 그림 · 해상도 올리기'), ), body: ListView(padding: const EdgeInsets.all(12), children: [AiSettings(c: c)]), ), diff --git a/lib/ui/ai_upscale/upscale_compare.dart b/lib/ui/ai_upscale/upscale_compare.dart index eff0899..4ff9db8 100644 --- a/lib/ui/ai_upscale/upscale_compare.dart +++ b/lib/ui/ai_upscale/upscale_compare.dart @@ -13,6 +13,7 @@ import '../../l10n/tr.dart'; import '../theme.dart'; import 'upscale_prep.dart'; import '../ai_upscale_ui.dart'; +import '../jj_title_bar.dart'; /// 4배 결과 [four] 를 [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 끝나면 임시 결과를 지운다 Future compareAndSaveUpscale(NavigatorState nav, AppController c, String four, @@ -93,9 +94,12 @@ class UpscaleCompareViewState extends State { Widget _page(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - appBar: AppBar( + // 원본 · 올린 것 고르기가 넓어 좁으면 제목 아래 줄로 + appBar: jjTitleBar( + context, + twoLinesBelow: 480, leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close), - title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), + title: widget.title, actions: [ SegmentedButton( segments: [ diff --git a/lib/ui/android_file_browser.dart b/lib/ui/android_file_browser.dart index 680990c..00ba655 100644 --- a/lib/ui/android_file_browser.dart +++ b/lib/ui/android_file_browser.dart @@ -7,6 +7,7 @@ import '../platform/android/android_storage.dart'; import 'bookmark_ui.dart' show askText; import 'theme.dart'; import '../l10n/tr.dart'; +import 'jj_title_bar.dart'; /// 마지막으로 본 폴더 (다음에 열 때 여기서) String? _lastDir; @@ -160,14 +161,17 @@ class _FileBrowserState extends State<_FileBrowser> { if (!didPop && dir != null) _open(p.dirname(dir)); }, child: Scaffold( - appBar: AppBar( + // 새 폴더 · 고르기 단추가 넓어 좁으면 제목 아래 줄로 + appBar: jjTitleBar( + context, + twoLinesBelow: 480, // 닫기: 어느 폴더에 있든 바로 닫는다 (뒤로 가기 키는 위 폴더로) leading: IconButton( tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), - title: Text(widget.title), + title: widget.title, actions: [ // 숨긴 항목 (. 으로 시작) 보이기 / 숨기기 IconButton( diff --git a/lib/ui/app_manager_page.dart b/lib/ui/app_manager_page.dart index 03a991b..2fa2474 100644 --- a/lib/ui/app_manager_page.dart +++ b/lib/ui/app_manager_page.dart @@ -8,6 +8,7 @@ import '../core/file_ops.dart' show formatSize; import '../core/vfs.dart' show vDisplay; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, Android): 실행할 수 있는 앱 목록 - 이름 · 판 · 크기 · 설치일, /// [APK 백업] (Download/JJ_MKVMaker/apk) · [앱 정보] (시스템 화면) · [지우기] (시스템 지우기 창) @@ -103,8 +104,9 @@ class _AppManagerPageState extends State { Widget build(BuildContext context) { final apps = _apps; return Scaffold( - appBar: AppBar( - title: Text(tr('앱 관리자')), + appBar: jjTitleBar( + context, + title: tr('앱 관리자'), actions: [IconButton(tooltip: tr('새로 고침'), icon: const Icon(Icons.refresh), onPressed: _load)], ), body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/ui/dlna_page.dart b/lib/ui/dlna_page.dart index c760f4d..f00e3c1 100644 --- a/lib/ui/dlna_page.dart +++ b/lib/ui/dlna_page.dart @@ -12,6 +12,7 @@ import '../l10n/tr.dart'; import 'player_page.dart'; import 'reader_page.dart' show openReader; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만"): 같은 Wi-Fi 의 미디어 서버 보기 · 재생 (서버 · 올리기 · 지우기 없음) Future openDlna(BuildContext context, AppController c) => @@ -187,9 +188,10 @@ class _DlnaPageState extends State { if (!didPop) _up(); }, child: Scaffold( - appBar: AppBar( - title: Text(s == null ? tr('미디어 서버 (DLNA)') : [s.name, ..._path.skip(1).map((x) => x.$2)].join(' / '), - maxLines: 1, overflow: TextOverflow.ellipsis), + appBar: jjTitleBar( + context, + path: true, + title: s == null ? tr('미디어 서버 (DLNA)') : [s.name, ..._path.skip(1).map((x) => x.$2)].join(' / '), leading: s == null ? null : IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: _up), actions: [ if (s == null) diff --git a/lib/ui/explorer_grid.dart b/lib/ui/explorer_grid.dart index 090423e..9208399 100644 --- a/lib/ui/explorer_grid.dart +++ b/lib/ui/explorer_grid.dart @@ -6,6 +6,7 @@ import '../core/file_ops.dart'; import '../core/playlist.dart' show isVideoFile; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 g: 격자 보기 - 지금 폴더의 그림 · 동영상을 썸네일 격자로 (X-plore 의 그림 보기 방식). /// 누르면 열기 (그림 보기 · 재생), 길게 누르면 탐색기에서 그 자리로. 돌려주는 값: "그 자리로" 고른 경로 (없으면 null) @@ -44,8 +45,9 @@ class _ExplorerGridPageState extends State { Widget build(BuildContext context) { final items = ExplorerGridPage.pick(widget.entries, widget.imageExts); return Scaffold( - appBar: AppBar( - title: Text(trf('{0} · 그림 · 동영상 {1}개', [widget.title, items.length]), maxLines: 1, overflow: TextOverflow.ellipsis), + appBar: jjTitleBar( + context, + title: trf('{0} · 그림 · 동영상 {1}개', [widget.title, items.length]), actions: [ IconButton( tooltip: tr('작게'), diff --git a/lib/ui/explorer_keys_page.dart b/lib/ui/explorer_keys_page.dart index 80c79ee..8d2bae9 100644 --- a/lib/ui/explorer_keys_page.dart +++ b/lib/ui/explorer_keys_page.dart @@ -6,6 +6,7 @@ import '../l10n/tr.dart'; import 'explorer_keys.dart'; import 'explorer_look.dart' show ExplorerStyle; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 바꾸기 - 동작마다 지금 키 · [바꾸기] (다음에 누른 키) · [처음 값으로]. /// 지금 고른 스타일의 처음 키를 보이고, 바꾼 키는 모든 스타일에 쓰인다 @@ -36,8 +37,9 @@ class ExplorerKeysPage extends StatelessWidget { final ov = c.settings.explorer.keyOverrides; final keys = effectiveExplorerKeys(tc: _tc, tcLook: _tc, overrides: ov); return Scaffold( - appBar: AppBar( - title: Text(tr('파일 탐색기 단축키')), + appBar: jjTitleBar( + context, + title: tr('파일 탐색기 단축키'), actions: [ if (ov.isNotEmpty) TextButton( diff --git a/lib/ui/explorer_search.dart b/lib/ui/explorer_search.dart index 691877e..2342fac 100644 --- a/lib/ui/explorer_search.dart +++ b/lib/ui/explorer_search.dart @@ -10,6 +10,7 @@ import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; +import 'jj_title_bar.dart'; /// 79: 찾기 조건 - 이름 (없어도 됨) · 크기 · 바뀐 날 · 종류 ("1GB 넘는 30일 지난 영상") class SearchConditions { @@ -370,8 +371,9 @@ class _SearchResultsPageState extends State { 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), + appBar: jjTitleBar( + context, + title: trf('찾기 결과 · {0}개', [_found.length]), actions: [ if (!_done && !_paused) const Padding( diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index b4fcbf8..de8a04f 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -11,6 +11,7 @@ import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_field.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다) @@ -172,7 +173,7 @@ class _HexViewerPageState extends State { final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16; final rows = (_data.length + per - 1) ~/ per; return Scaffold( - appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), + appBar: jjTitleBar(context, title: trf('16진 보기 · {0}', [p.basename(widget.path)])), body: _error != null ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) : Column(children: [ diff --git a/lib/ui/jj_title_bar.dart b/lib/ui/jj_title_bar.dart new file mode 100644 index 0000000..26fa8ea --- /dev/null +++ b/lib/ui/jj_title_bar.dart @@ -0,0 +1,39 @@ +/// 약속 (W 공용 위젯, 설계 7-2 JjTitleBar - 쪽 위 막대): 제목 · 앞 단추 · 뒤 단추들 → 위 막대 (Scaffold.appBar). +/// 입력: 제목 글 (한 줄 - 넘치면 끝을 "…", [path] 면 앞을 "…" 로 줄여 끝 (지금 폴더) 이 보임 - [TailText]) · 앞 단추 (없으면 Flutter 처음 값: +/// 돌아갈 곳이 있으면 화면 언어의 뒤로) · 뒤 단추들 · [twoLinesBelow] (화면 폭이 이보다 좁으면 뒤 단추들을 제목 아래 둘째 줄로 - 넓은 단추를 가진 쪽). +/// 출력: AppBar (모양은 앱 테마). 둘째 줄도 넘치면 그 줄만 줄여 모두 보이게 하며, 단추를 숨기지 않는다. 오류 없음. +/// 쓰는 곳: 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA · AI 비교 등 쪽 위 막대. 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +import 'tail_text.dart'; + +PreferredSizeWidget jjTitleBar( + BuildContext context, { + required String title, + bool path = false, + Widget? leading, + List actions = const [], + double twoLinesBelow = 0, +}) { + final twoLines = actions.isNotEmpty && MediaQuery.sizeOf(context).width < twoLinesBelow; + return AppBar( + leading: leading, + title: path ? TailText(title) : Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: twoLines ? null : actions, + bottom: twoLines + ? PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Align( + alignment: Alignment.centerRight, + // 둘째 줄에도 다 안 들어가면 (좁은 폭 · 큰 글자) 줄 전체를 그만큼만 줄인다 - 단추를 숨기거나 화면 밖으로 밀지 않음 + child: FittedBox(fit: BoxFit.scaleDown, child: Row(mainAxisSize: MainAxisSize.min, children: actions)), + ), + ), + ) + : null, + ); +} diff --git a/lib/ui/license_texts.dart b/lib/ui/license_texts.dart index 91e4339..f97c864 100644 --- a/lib/ui/license_texts.dart +++ b/lib/ui/license_texts.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart' show rootBundle; import '../core/ai_catalog.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// 62 · 63 · 64: 라이선스 원문 (assets/licenses - Windows 는 실행 파일 옆에도 같은 파일) /// 64: 고지 문서는 플랫폼마다 (Android 에는 Windows 파일 이름 · 경로를 보이지 않게) @@ -84,8 +85,8 @@ Future showLicenseDocument(BuildContext context, String title, String asse } if (!context.mounted) return; await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => Scaffold( - appBar: AppBar(title: Text(title)), + builder: (ctx) => Scaffold( + appBar: jjTitleBar(ctx, title: title), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: SelectableText(text, style: const TextStyle(fontFamily: 'monospace', fontSize: 12, height: 1.4)), diff --git a/lib/ui/sqlite_viewer.dart b/lib/ui/sqlite_viewer.dart index f29d211..37a2c73 100644 --- a/lib/ui/sqlite_viewer.dart +++ b/lib/ui/sqlite_viewer.dart @@ -8,6 +8,7 @@ import '../core/sqlite_reader.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// SQLite 로 볼 확장자 const sqliteExtensions = {'db', 'sqlite', 'sqlite3', 'db3'}; @@ -144,8 +145,9 @@ class _SqliteViewerPageState extends State { Widget build(BuildContext context) { final t = _table; return Scaffold( - appBar: AppBar( - title: Text(vBasename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis), + appBar: jjTitleBar( + context, + title: vBasename(widget.path), ), body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index 6ac8cd8..2ce7fc3 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -6,6 +6,7 @@ import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'storage_chart.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. /// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값). @@ -135,8 +136,9 @@ class _StorageAnalysisPageState extends State { if (!didPop && canUp) setState(() => _cur = vDirname(_cur)); }, child: Scaffold( - appBar: AppBar( - title: Text(tr('저장 공간 분석')), + appBar: jjTitleBar( + context, + title: tr('저장 공간 분석'), actions: [ if (s == null && _error == null) TextButton( diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 373d856..f066d68 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -10,6 +10,7 @@ import '../core/file_ops.dart' show formatSize; import '../core/text_codec.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 h: 글 뷰어 - 글 파일 전체를 읽기 전용으로. 큰 파일은 앞에서부터 나눠 읽고 [더 읽기], /// 글자 코드는 스스로 판별 (UTF-8 · UTF-16 · CP949 · Shift-JIS · GBK) 하고 위에서 바꿀 수 있다. 줄 번호 · 글자 크기 · 복사. @@ -186,8 +187,9 @@ class _TextViewerPageState extends State { // 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다) final narrow = MediaQuery.sizeOf(context).width < 600; return Scaffold( - appBar: AppBar( - title: Text(p.basename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis), + appBar: jjTitleBar( + context, + title: p.basename(widget.path), actions: [ if (_charset != null) DropdownButton( diff --git a/lib/ui/trash_page.dart b/lib/ui/trash_page.dart index 0b84f25..8e85e14 100644 --- a/lib/ui/trash_page.dart +++ b/lib/ui/trash_page.dart @@ -5,6 +5,7 @@ import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'confirm.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 e: 앱 휴지통 화면 (Android) - 항목 · 원래 자리 · 지운 때, 골라서 되살리기 · 영구 삭제, 모두 비우기. /// 돌려주는 값: 되살린 경로들 (탐색기가 그 폴더를 새로 읽는다) @@ -97,8 +98,9 @@ class _TrashPageState extends State { if (!didPop) Navigator.pop(context, _restored); }, child: Scaffold( - appBar: AppBar( - title: Text(items == null ? tr('휴지통') : trf('휴지통 · {0}개 · {1}', [items.length, formatSize(total)])), + appBar: jjTitleBar( + context, + title: items == null ? tr('휴지통') : trf('휴지통 · {0}개 · {1}', [items.length, formatSize(total)]), actions: [ if (items != null && items.isNotEmpty) TextButton.icon( diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index 43c7269..79640bc 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -17,6 +17,7 @@ import '../l10n/tr.dart'; import 'vault/vault_actions.dart'; import 'vault/vault_session.dart'; import 'vault/vault_views.dart'; +import 'jj_title_bar.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) /// 약속 (보관함 나누기): 이 화면은 단계 (확인 · 만들기 · 잠김 · 여는 중 · 열림) 와 열고 잠그기만 맡고, @@ -281,8 +282,9 @@ class _VaultPageState extends State with WidgetsBindingObserver { } Widget _scaffold() => Scaffold( - appBar: AppBar( - title: Text(tr('암호 보관함')), + appBar: jjTitleBar( + context, + title: tr('암호 보관함'), actions: [ if (_stage == _Stage.open) ...[ IconButton(tooltip: tr('잠그기'), icon: const Icon(Icons.lock_outline), onPressed: () => setState(_lock)), diff --git a/lib/ui/wifi_share_page.dart b/lib/ui/wifi_share_page.dart index c425cdd..b3ad089 100644 --- a/lib/ui/wifi_share_page.dart +++ b/lib/ui/wifi_share_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,7 @@ import '../core/wifi_share.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import '../core/wifi_share_net.dart'; +import 'jj_title_bar.dart'; /// 89: Wi-Fi 공유 화면 - 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 켜기 전: 폴더 · 저절로 끄는 시간 · 안내 · [켜기]. 켠 뒤: 6자리 코드 (크게) · 주소 · QR · 남은 시간 [+30분] · [끄기] · 접속 기록. @@ -22,7 +24,10 @@ class WifiSharePage extends StatefulWidget { /// 열 주소 · Windows 설정 · 제어판 열기 (처음 값 = 이 기기) final WifiShareNet net; - const WifiSharePage({super.key, required this.c, required this.folder, this.net = const RealWifiShareNet()}); + + /// 접속 코드를 만드는 난수 (처음 값 = 안전한 난수, [WifiShare.start] 와 같음) + final Random? random; + const WifiSharePage({super.key, required this.c, required this.folder, this.net = const RealWifiShareNet(), this.random}); static Future open(BuildContext context, AppController c, String folder) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder))); @@ -75,7 +80,7 @@ class _WifiSharePageState extends State { try { final mins = widget.c.settings.tools.wifiShareMinutes; final s = await WifiShare.start(widget.folder, - autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale, net: widget.net); + autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale, net: widget.net, random: widget.random); await widget.c.updateSettings((x) => x.tools.wifiShareFolder = widget.folder); _s?.removeListener(_changed); _s = s..addListener(_changed); @@ -97,7 +102,7 @@ class _WifiSharePageState extends State { Widget build(BuildContext context) { final s = _s; return Scaffold( - appBar: AppBar(title: Text(tr('Wi-Fi 공유'))), + appBar: jjTitleBar(context, title: tr('Wi-Fi 공유')), body: SafeArea( child: ListView( padding: const EdgeInsets.all(16), diff --git a/lib/ui/zip_page.dart b/lib/ui/zip_page.dart index f7a9e9d..8506de5 100644 --- a/lib/ui/zip_page.dart +++ b/lib/ui/zip_page.dart @@ -9,6 +9,7 @@ import '../l10n/tr.dart'; import 'archive_ui.dart'; import 'reader_page.dart'; import 'theme.dart'; +import 'jj_title_bar.dart'; /// ZIP · CBZ 목록: 안의 파일을 보고, 골라서 (또는 모두) 다른 곳으로 풀고, 그림이 있으면 만화처럼 본다. /// [otherDir] 파일 탐색기의 다른 창 폴더 (풀 곳으로 먼저 보여 줌). 풀었으면 그 폴더를 돌려준다. @@ -73,8 +74,9 @@ class _ZipPageState extends State { Widget build(BuildContext context) { final entries = _entries; return Scaffold( - appBar: AppBar( - title: Text(vBasename(widget.path), overflow: TextOverflow.ellipsis), + appBar: jjTitleBar( + context, + title: vBasename(widget.path), actions: [ if (_hasImages) IconButton( diff --git a/test/jj_title_bar_contract_test.dart b/test/jj_title_bar_contract_test.dart new file mode 100644 index 0000000..ba96c76 --- /dev/null +++ b/test/jj_title_bar_contract_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/jj_title_bar.dart'; +import 'package:jj_mkvmaker/ui/tail_text.dart'; + +/// W 공용 위젯 약속 (JjTitleBar): 제목은 한 줄 (넘치면 "…", 경로는 끝이 보이게) · 좁으면 뒤 단추들을 둘째 줄로 (숨기지 않음) · +/// 300 · 412 폭 · 글자 배율 2 에서 넘치지 않음. 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + const long = '휴지통 · 1234개 · 56.7 GB - 아주 긴 제목이 이어져 한 줄에 다 들어가지 않는 경우'; + + Future show(WidgetTester t, PreferredSizeWidget Function(BuildContext) bar, {double width = 412, double scale = 1}) async { + t.view.physicalSize = Size(width, 800); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 800), textScaler: TextScaler.linear(scale)), + child: Builder(builder: (ctx) => Scaffold(appBar: bar(ctx), body: const SizedBox.expand())), + ), + )); + } + + List wide() => [ + OutlinedButton.icon(onPressed: () {}, icon: const Icon(Icons.create_new_folder_outlined), label: const Text('새 폴더')), + FilledButton(onPressed: () {}, child: const Text('이 폴더 고르기')), + IconButton(tooltip: '보이기', icon: const Icon(Icons.visibility_off_outlined), onPressed: () {}), + ]; + + for (final width in [300.0, 412.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets('폭 $width · 배율 $scale: 긴 제목 한 줄 · 넘치지 않음', (t) async { + await show(t, (ctx) => jjTitleBar(ctx, title: long, actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: () {})]), + width: width, scale: scale); + expect(t.takeException(), isNull); + final text = t.widget(find.text(long)); + expect([text.maxLines, text.overflow], [1, TextOverflow.ellipsis]); + expect(t.getRect(find.byIcon(Icons.refresh)).right, lessThanOrEqualTo(width)); + }); + + testWidgets('폭 $width · 배율 $scale: 넓은 단추는 좁으면 둘째 줄 - 모두 보이고 넘치지 않음', (t) async { + await show(t, (ctx) => jjTitleBar(ctx, title: long, actions: wide(), twoLinesBelow: 480), width: width, scale: scale); + expect(t.takeException(), isNull); + final title = t.getRect(find.text(long)); + for (final f in [find.text('새 폴더'), find.text('이 폴더 고르기'), find.byIcon(Icons.visibility_off_outlined)]) { + expect(f, findsOneWidget); + final r = t.getRect(f); + expect(r.top, greaterThan(title.bottom), reason: '제목 아래 줄'); + expect([r.left >= 0, r.right <= width], [true, true], reason: '화면 안에 다 보임'); + } + }); + } + } + + testWidgets('넓은 화면에서는 한 줄 (둘째 줄 없음)', (t) async { + await show(t, (ctx) => jjTitleBar(ctx, title: '제목', actions: wide(), twoLinesBelow: 480), width: 960); + expect(t.takeException(), isNull); + expect((t.getRect(find.text('새 폴더')).center.dy - t.getRect(find.text('제목')).center.dy).abs(), lessThan(4)); + }); + + testWidgets('경로 제목은 끝 (지금 폴더) 이 보임', (t) async { + const path = 'NAS / 동영상 / 2026 / 여름 휴가 / 바다에서 찍은 것'; + await show(t, (ctx) => jjTitleBar(ctx, title: path, path: true), width: 300); + expect(t.takeException(), isNull); + expect(find.byType(TailText), findsOneWidget); + expect(find.textContaining('바다에서 찍은 것'), findsOneWidget); + }); +}