W NoticeBand 1: 공용 안내 띠 (lib/ui/notice_band.dart) - 파일 탐색기 안내가 이것을 부름 (모양 그대로)

- NoticeBand(icon, title, body, actions, color): 연한 바탕 · 테두리 · 둥근 띠, 제목 굵게 · 설명 작고 흐리게 · 단추는 넘치면 다음 줄 (Wrap).
  탐색기 pane_notices 의 _noticeBox 와 같은 모양 (탐색기 캡처 214장 0px)
- 약속 시험 notice_band_contract (27): 폭 300 · 412 · 960 × 글자 배율 1 · 1.3 · 2 × ko · en · ja - 넘침 없음 · 단추가 띠 안 · 좁고 크면 다음 줄
- 캡처 시험 notice_places_shot (나머지 자리를 모으기 전후 견줄 것): 실시간 동기화 카드 (멈춤 · 마스터 기다림) · 동영상 자세히 오류 상자 · AI 서비스 경고

사용자가 보게 될 동작: 바뀌는 것 없음 (탐색기 띠 모양 그대로)

확인: analyze 0 · 단위 1525 ×2 · 통합 6/6 ×2 · base10 214장 0px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 16:10:18 +09:00
parent e93b0b0be6
commit dac47bdd20
4 changed files with 191 additions and 30 deletions

View File

@ -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<NavigatorState>();
Future<void> 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<void>.delayed(const Duration(milliseconds: 40)));
await t.pump(const Duration(milliseconds: 100));
}
}
Future<void> 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;
});
}
}

View File

@ -14,6 +14,7 @@ import '../../l10n/tr.dart';
import '../../platform/android/android_storage.dart'; import '../../platform/android/android_storage.dart';
import '../file_error.dart'; import '../file_error.dart';
import '../ftp_settings.dart'; import '../ftp_settings.dart';
import '../notice_band.dart';
import '../sftp_settings.dart'; import '../sftp_settings.dart';
import '../theme.dart'; import '../theme.dart';
import 'access_flow.dart'; import 'access_flow.dart';
@ -66,36 +67,9 @@ class ExplorerNotices {
); );
} }
/// 목록 위 안내 상자 /// 목록 위 안내 상자 (공용 NoticeBand - W)
Widget _notice( Widget _notice({required IconData icon, required String title, required String body, required List<Widget> actions, Color? color}) =>
{required IconData icon, NoticeBand(icon: icon, title: title, body: body, actions: actions, color: color);
required String title,
required String body,
required List<Widget> actions,
Color? color}) =>
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
Widget _noticeBox(IconData icon, String title, String body, List<Widget> 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),
]),
);
// ───────── 만들다 만 파일 (71) ───────── // ───────── 만들다 만 파일 (71) ─────────

46
lib/ui/notice_band.dart Normal file
View File

@ -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<Widget> 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),
]),
);
}
}

View File

@ -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: '좁고 크면 단추가 다음 줄로');
}
});
}
}
}
}