W JjTitleBar 1: 쪽 위 막대 (AppBar) 17곳을 jjTitleBar 하나로 + 캡처 흔들림 wifi · vault 고정
- lib/ui/jj_title_bar.dart: 제목은 늘 한 줄 (넘치면 "…", 경로는 TailText 로 끝이 보이게) · 넓은 단추를 가진 쪽은 좁으면 단추들을 제목 아래 둘째 줄로 (거기도 넘치면 그 줄만 줄임, 숨기지 않음). 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA (경로) · AI 비교 · 앱 안 폴더 고르기 등 17곳이 부르기만 한다 - 바뀐 동작: AI 올리기 비교 · 앱 안 폴더 고르기는 좁은 폭에서 단추가 제목 아래 줄로 (예전에는 412 ja 폴더 고르기가 넘쳤음) - 주입: WifiSharePage.random (접속 코드 난수, 처음 값 안전한 난수) · Vault.add(added) (넣은 때, 처음 값 지금) - 시험: jj_title_bar_contract_test · titlebar_places_shot_test. wifi (폴더 · 코드 · 스크롤 막대) · vault (폴더 · 넣은 날) 고정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>modular
parent
2d9968929d
commit
21e1d90f82
|
|
@ -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<NavigatorState>();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: key,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: nav, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
|
||||
Future<void> settle() async {
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<bool>(
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))]) {
|
||||
|
|
|
|||
|
|
@ -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<void> 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');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,9 +348,9 @@ class Vault {
|
|||
}
|
||||
|
||||
/// [src] 를 암호화해 [folder] 에 넣는다 (원본은 그대로 - 지우기는 부르는 쪽이 탐색기의 지우기로). 이름이 겹치면 " (2)".
|
||||
/// [onProgress] (넣은 바이트, 전체)
|
||||
/// [onProgress] (넣은 바이트, 전체). [added] 넣은 때 (처음 값 = 지금 - 목록의 날짜)
|
||||
Future<VaultEntry> 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();
|
||||
|
|
|
|||
|
|
@ -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<AiFile> aiMissingFiles(AiStore store) => [
|
||||
|
|
@ -33,10 +34,11 @@ int aiMissingSize(AiStore store) => aiMissingFiles(store).fold<int>(0, (a, f) =>
|
|||
/// AI 그림 화면의 [받기 화면으로] · 설정 버튼: AI 설정 (모델 받기 · 처리 장치 · 서비스) 을 따로 연다
|
||||
Future<void> openAiModels(BuildContext context, AppController c) => Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
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)]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<String?> compareAndSaveUpscale(NavigatorState nav, AppController c, String four,
|
||||
|
|
@ -93,9 +94,12 @@ class UpscaleCompareViewState extends State<UpscaleCompareView> {
|
|||
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<bool>(
|
||||
segments: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<AppManagerPage> {
|
|||
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: [
|
||||
|
|
|
|||
|
|
@ -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<void> openDlna(BuildContext context, AppController c) =>
|
||||
|
|
@ -187,9 +188,10 @@ class _DlnaPageState extends State<DlnaPage> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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<ExplorerGridPage> {
|
|||
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('작게'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<SearchResultsPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<HexViewerPage> {
|
|||
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: [
|
||||
|
|
|
|||
|
|
@ -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<Widget> 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void> showLicenseDocument(BuildContext context, String title, String asse
|
|||
}
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
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)),
|
||||
|
|
|
|||
|
|
@ -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<SqliteViewerPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<StorageAnalysisPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<TextViewerPage> {
|
|||
// 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (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<String>(
|
||||
|
|
|
|||
|
|
@ -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<TrashPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<VaultPage> 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)),
|
||||
|
|
|
|||
|
|
@ -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<void> 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<WifiSharePage> {
|
|||
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<WifiSharePage> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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<ZipPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<void> 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<Widget> 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<Text>(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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue