diff --git a/integration_test/notice_places_shot_test.dart b/integration_test/notice_places_shot_test.dart new file mode 100644 index 0000000..a25c587 --- /dev/null +++ b/integration_test/notice_places_shot_test.dart @@ -0,0 +1,88 @@ +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:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/files/transfer_guard.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/webdav.dart' show DavLockedException; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_settings.dart' show editAiService; +import 'package:jj_mkvmaker/ui/home/video_detail.dart'; +import 'package:jj_mkvmaker/ui/monitor/live_pair_card.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// W NoticeBand (관리자 10/24): 안내 띠를 하나로 모을 때 캡처가 없던 곳을 찍는다 - 실시간 동기화 카드 (멈춤 · 마스터 기다림) · +/// 동영상 자세히의 오류 상자 · AI 서비스 추가 창의 경고. 412 · 960. 모으기 전 판과 뒤 판을 같은 이름으로 찍어 견준다 (JJ_SHOT_DIR) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_notice'); + + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('안내 띠 자리 ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey(); + final nav = GlobalKey(); + + Future show(Widget body) async { + await t.pumpWidget(RepaintBoundary( + key: key, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: nav, home: Scaffold(body: body)), + )); + for (var i = 0; i < 5; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future shot(String name) async { + expect(t.takeException(), isNull, reason: '$name 넘침'); + final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'notice_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // 1 · 2. 실시간 동기화 카드: 원본을 읽을 수 없어 멈춤 · 마스터 비밀번호 기다림 + final pair = LiveSyncPair(r'C:\src_missing', r'D:\dst'); + final live = LiveSync(c); + for (final (name, problem) in [ + ('live_stopped', SourceUnreadableException(r'C:\src_missing', cause: 'gone')), + ('live_locked', SourceUnreadableException(r'C:\src_missing', cause: const DavLockedException('locked'))), + ]) { + live.problems[LiveSync.keyOf(pair)] = problem; + await show(SingleChildScrollView( + child: LivePairCard(c: c, live: live, pair: pair, onFinalize: () {}, onShowCopyList: () {}))); + await shot(name); + } + + // 3. 동영상 자세히: 오류 상자 + final v = VideoItem(r'C:\videos\movie.mkv')..message = 'MKV 만들기 실패: 쓸 공간이 모자랍니다 (D:)'; + await show(VideoDetailPane(c: c, v: v)); + await shot('video_error'); + + // 4. AI 서비스 추가 창: 기기 밖으로 나간다는 경고 + await show(const SizedBox.expand()); + final open = editAiService(nav.currentContext!, c); + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 100)); + } + await shot('ai_service'); + Navigator.of(nav.currentContext!).pop(); + await t.pump(const Duration(milliseconds: 300)); + await open; + }); + } +} diff --git a/lib/ui/explorer/pane_notices.dart b/lib/ui/explorer/pane_notices.dart index 666a07d..890f40d 100644 --- a/lib/ui/explorer/pane_notices.dart +++ b/lib/ui/explorer/pane_notices.dart @@ -14,6 +14,7 @@ import '../../l10n/tr.dart'; import '../../platform/android/android_storage.dart'; import '../file_error.dart'; import '../ftp_settings.dart'; +import '../notice_band.dart'; import '../sftp_settings.dart'; import '../theme.dart'; import 'access_flow.dart'; @@ -66,36 +67,9 @@ class ExplorerNotices { ); } - /// 목록 위 안내 상자 - Widget _notice( - {required IconData icon, - required String title, - required String body, - required List actions, - Color? color}) => - _noticeBox(icon, title, body, actions, color ?? JjColors.alert); - - Widget _noticeBox(IconData icon, String title, String body, List actions, Color color) => - Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(6, 6, 6, 0), - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.5)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 8), - Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), - ]), - const SizedBox(height: 4), - Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - Wrap(spacing: 8, children: actions), - ]), - ); + /// 목록 위 안내 상자 (공용 NoticeBand - W) + Widget _notice({required IconData icon, required String title, required String body, required List actions, Color? color}) => + NoticeBand(icon: icon, title: title, body: body, actions: actions, color: color); // ───────── 만들다 만 파일 (71) ───────── diff --git a/lib/ui/notice_band.dart b/lib/ui/notice_band.dart new file mode 100644 index 0000000..08c380f --- /dev/null +++ b/lib/ui/notice_band.dart @@ -0,0 +1,46 @@ +/// 약속 (W 공용 위젯, 설계 7-2 NoticeBand): 목록 · 화면 위에 띄우는 안내 띠 하나. +/// 입력: 아이콘 · 제목 · 설명 · 단추들 · 색 (처음 값 경고색). 출력: 연한 바탕 · 테두리 · 둥근 띠 - 제목은 굵게, 설명은 작고 흐리게, +/// 단추는 넘치면 다음 줄로 (Wrap). 오류 없음. 폭 · 글자 배율 · 언어에 따라 제목 · 설명은 줄을 바꾼다 (넘치지 않음). +/// 쓰는 곳: 파일 탐색기 안내 (권한 · 읽지 못함 · 만들다 만 파일 · 시스템 폴더 · 루트 · 서버 열쇠). 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +class NoticeBand extends StatelessWidget { + const NoticeBand({super.key, required this.icon, required this.title, required this.body, this.actions = const [], this.color}); + + final IconData icon; + final String title; + final String body; + final List actions; + + /// 띠 색 (처음 값 [JjColors.alert]) + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? JjColors.alert; + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(6, 6, 6, 0), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: c.withValues(alpha: 0.5)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 18, color: c), + const SizedBox(width: 8), + Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), + ]), + const SizedBox(height: 4), + Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + Wrap(spacing: 8, children: actions), + ]), + ); + } +} diff --git a/test/notice_band_contract_test.dart b/test/notice_band_contract_test.dart new file mode 100644 index 0000000..ac50493 --- /dev/null +++ b/test/notice_band_contract_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/notice_band.dart'; + +/// W 공용 위젯 약속 (NoticeBand): 폭 · 글자 배율 · 언어 조합에서 넘치지 않고 (제목 · 설명은 줄을 바꿈), 단추는 넘치면 다음 줄로. +/// 위젯 하나에 대해 한 번만 둔다 (설계 7-2) - 화면마다 띠 넘침 시험을 더하지 않는다 +void main() { + const texts = { + 'ko': ('이 폴더를 읽을 수 없습니다', '저장소 권한이 없거나 다른 앱이 쓰는 중입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.', ['권한 허용 화면 열기', '허용했으면 다시 확인', '닫기']), + 'en': ('Cannot read this folder', 'There is no storage permission or another app is using it. Turn on JJ_MKVMaker in the settings screen and come back.', + ['Open permission screen', 'Check again', 'Close']), + 'ja': ('このフォルダーを読み込めません', 'ストレージの権限がないか、別のアプリが使用中です。許可画面で JJ_MKVMaker をオンにして戻ってください。', ['許可画面を開く', '許可したら再確認', '閉じる']), + }; + + for (final width in [300.0, 412.0, 960.0]) { + for (final scale in [1.0, 1.3, 2.0]) { + for (final MapEntry(key: lang, value: (title, body, buttons)) in texts.entries) { + testWidgets('$lang · 폭 $width · 배율 $scale: 넘치지 않음 · 단추 모두 보임', (t) async { + t.view.physicalSize = Size(width, 1200); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 1200), textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: NoticeBand( + icon: Icons.lock_outline, + title: title, + body: body, + actions: [for (final b in buttons) TextButton(onPressed: () {}, child: Text(b))], + ), + ), + ), + ), + )); + expect(t.takeException(), isNull, reason: '넘침 없음'); + for (final b in buttons) { + final r = t.getRect(find.text(b)); + expect(r.right <= width && r.left >= 0, isTrue, reason: '단추 "$b" 가 띠 안에'); + } + final band = t.getRect(find.byType(NoticeBand)); + expect(band.width, lessThanOrEqualTo(width)); + if (width == 300 && scale == 2.0) { + final ys = {for (final b in buttons) t.getRect(find.text(b)).top}; + expect(ys.length, greaterThan(1), reason: '좁고 크면 단추가 다음 줄로'); + } + }); + } + } + } +}