From 9b03f20d8f495eaa4fa6b29cd516dac02037fe85 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:48:23 +0900 Subject: [PATCH] =?UTF-8?q?W=20NoticeBand=202:=20=EB=82=98=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=EC=95=88=EB=82=B4=20=EB=9D=A0=20=EB=8B=A4=EC=84=AF?= =?UTF-8?q?=20=EA=B3=B3=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20=EB=9D=A0=EB=A1=9C?= =?UTF-8?q?=20(=EA=B4=80=EB=A6=AC=EC=9E=90=2010/24=20=E2=91=A0=20-=20?= =?UTF-8?q?=EB=AA=A8=EC=96=91=EC=9D=84=20=ED=95=98=EB=82=98=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옮긴 곳: 실시간 동기화 카드 (멈춤 · 마스터 기다림) · AI 그림 화면 안내 2 · AI 서비스 창 경고 · FTP 평문 경고 · 동영상 오류 상자 - NoticeBand 더한 것: 설명은 없어도 됨 · selectable (오류 원문을 골라 복사 - 설명이 없으면 제목) · margin (부르는 쪽이 정함) - 글 · 단추 · 색의 뜻은 그대로 (경고 = warning · 오류 = danger/alert · 안내 = accent). 새 화면 글 없음 - 범위 밖: Tor 상태 줄 · 브라우저 띠 (진행 막대가 든 상태 줄 - 안내 띠가 아님) - 시험 기대값 바꿈 1곳: sync_problem_test 의 '대상 파일은 지우지 않았습니다.' 를 따로 된 글이 아니라 띠 설명 안의 글로 찾음 (textContaining - 그 문장이 보인다는 뜻은 그대로) 캡처 (바꾸기 전 판 dac47bd 2회 · 바꾼 판 2회, 전후 18장: M:\jj_tmp\dev\7f\nb_compare): - 바뀐 것은 옮긴 화면 18장뿐 (AI 그림 4 · FTP 평문 6 · 새 캡처 notice_places 8), 다른 화면은 그대로. 412 폭 넘침 없음 - 새 캡처 8장은 바뀐 판 두 번 0px. AI 그림 넓은 2장 · FTP 평문 960 2장은 바꾸기 전에도 흔들리던 장 (글 칸 커서) 사용자가 보게 될 동작: 안내 띠가 모두 같은 모양 (연한 바탕 · 테두리 · 아이콘 · 굵은 제목 · 작은 설명 · 단추). FTP 평문 경고는 빨간 글 상자에서 빨간 띠로 확인: analyze 0 · 단위 1525 ×2 · 통합 6/6 ×2 (b · c) · base10 214장 0px (nb3 통합 a 에서 412 지우기 "시험 휴지통 폴더가 없어짐" 이 다시 남 - 이 고침과 무관, 이제 step_check 가 늘 %TEMP% 감시를 켬) Co-Authored-By: Claude Opus 5.5 --- lib/ui/ai_image_page.dart | 18 +----- lib/ui/ai_settings.dart | 8 +-- lib/ui/ftp_settings.dart | 9 +-- lib/ui/home/video_detail.dart | 11 +--- lib/ui/monitor/live_pair_card.dart | 96 ++++++++++-------------------- lib/ui/notice_band.dart | 36 ++++++++--- test/sync_problem_test.dart | 3 +- 7 files changed, 71 insertions(+), 110 deletions(-) diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index 59852ff..6da7b48 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -18,6 +18,7 @@ import 'app_actions.dart'; import 'browser_page.dart' show browserRouteObserver; import 'reader_page.dart'; import 'theme.dart'; +import 'notice_band.dart'; /// 123: AI 그림 - 글 → 그림 · 그림 → 그림. 기기 안 (SD1.5 + LCM) 이 기본이고, 사용자가 추가한 서버 · 서비스도 같은 화면에서. /// 결과는 새 파일로 저장하고 (원본을 덮지 않음), 옆에 프롬프트 · 시드 · 모델을 json 으로 남긴다. @@ -423,19 +424,6 @@ class _AiImagePageState extends State with RouteAware { ), ]); - Widget _notice(IconData icon, Color color, String text, {Widget? action}) => Container( - margin: const EdgeInsets.only(top: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.5)), - ), - child: Row(children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 8), - Expanded(child: Text(text, style: const TextStyle(fontSize: 13))), - ?action, - ]), - ); + Widget _notice(IconData icon, Color color, String text, {Widget? action}) => + NoticeBand(icon: icon, color: color, title: text, margin: const EdgeInsets.only(top: 8), actions: [?action]); } diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 7e048e8..b4957eb 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -18,6 +18,7 @@ import 'license_texts.dart' show showLicenseDocument; import 'setting_tile.dart'; import 'theme.dart'; import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; +import 'notice_band.dart'; /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) List aiMissingFiles(AiStore store) => [ @@ -485,11 +486,8 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol content: SizedBox( width: 480, child: Column(mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(8), - color: JjColors.warning.withValues(alpha: 0.1), - child: Text(tr('이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.'), style: const TextStyle(fontSize: 12)), - ), + NoticeBand( + icon: Icons.public, color: JjColors.warning, margin: EdgeInsets.zero, title: tr('이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.')), TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'))), DropdownButtonFormField( initialValue: kind, diff --git a/lib/ui/ftp_settings.dart b/lib/ui/ftp_settings.dart index a3a4987..66e9d48 100644 --- a/lib/ui/ftp_settings.dart +++ b/lib/ui/ftp_settings.dart @@ -9,6 +9,7 @@ import 'confirm.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; +import 'notice_band.dart'; /// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀. /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다 @@ -243,13 +244,7 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ ]), ), if (security == 'none') - Container( - padding: const EdgeInsets.all(8), - color: JjColors.danger.withValues(alpha: 0.12), - child: Text( - tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.'), - style: TextStyle(fontSize: 12, color: JjColors.danger)), - ), + NoticeBand(icon: Icons.lock_open, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.')), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))), TextField( controller: pass, diff --git a/lib/ui/home/video_detail.dart b/lib/ui/home/video_detail.dart index 7f9c385..3e504e5 100644 --- a/lib/ui/home/video_detail.dart +++ b/lib/ui/home/video_detail.dart @@ -16,6 +16,7 @@ import '../theme.dart'; import '../../l10n/tr.dart'; import 'move_to_target.dart'; import 'subtitle_row.dart'; +import '../notice_band.dart'; class VideoDetailPane extends StatelessWidget { final AppController c; @@ -179,15 +180,7 @@ class VideoDetailPane extends StatelessWidget { ], if (v.message != null) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: JjColors.danger.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: SelectableText(v.message!, - style: TextStyle(color: JjColors.danger, fontSize: 12)), - ), + NoticeBand(icon: Icons.error_outline, color: JjColors.danger, margin: EdgeInsets.zero, title: v.message!, selectable: true), ], if (!compact) ...[ const SizedBox(height: 24), diff --git a/lib/ui/monitor/live_pair_card.dart b/lib/ui/monitor/live_pair_card.dart index 423968b..bd51558 100644 --- a/lib/ui/monitor/live_pair_card.dart +++ b/lib/ui/monitor/live_pair_card.dart @@ -26,6 +26,7 @@ import '../sync_preview_view.dart' show unbreakableOption; import '../theme.dart'; import 'live_sync_flow.dart'; import 'transfer_widgets.dart'; +import '../notice_band.dart'; /// 원본 문제 안내 (68 · 70): 원본을 읽지 못함 · 원본이 빔 · 마스터 비밀번호 기다림 class LiveSourceProblemPanel extends StatelessWidget { @@ -43,27 +44,13 @@ class LiveSourceProblemPanel extends StatelessWidget { final running = live.isRunning(x); // 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다 if (e.locked) { - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - decoration: BoxDecoration( - color: JjColors.accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)), - ), - ]), - const SizedBox(height: 4), - Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'), - style: const TextStyle(fontSize: 12)), - const SizedBox(height: 6), + return NoticeBand( + icon: Icons.lock_clock_outlined, + color: JjColors.accent, + margin: EdgeInsets.zero, + title: tr('마스터 비밀번호가 필요해 기다리는 중'), + body: tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'), + actions: [ FilledButton.tonalIcon( onPressed: running ? null @@ -73,54 +60,33 @@ class LiveSourceProblemPanel extends StatelessWidget { icon: const Icon(Icons.lock_open, size: 18), label: Text(tr('마스터 비밀번호 넣기')), ), - ]), + ], ); } - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - decoration: BoxDecoration( - color: JjColors.alert.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert), - ), - ), - ]), - const SizedBox(height: 4), - Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)), - Text( - e.empty - ? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. ' - '일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length]) - : tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'), - style: const TextStyle(fontSize: 12), + return NoticeBand( + icon: e.empty ? Icons.folder_off_outlined : Icons.error_outline, + margin: EdgeInsets.zero, + title: e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'), + body: [ + tr('대상 파일은 지우지 않았습니다.'), + e.empty + ? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. ' + '일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length]) + : tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'), + '${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}', + ].join('\n'), + actions: [ + FilledButton.tonalIcon( + onPressed: running ? null : () => live.syncNow(x), + icon: const Icon(Icons.refresh, size: 18), + label: Text(tr('다시 시도')), ), - const SizedBox(height: 4), - Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}', - style: TextStyle(fontSize: 11, color: JjColors.textDim)), - const SizedBox(height: 6), - Wrap(spacing: 8, runSpacing: 4, children: [ - FilledButton.tonalIcon( - onPressed: running ? null : () => live.syncNow(x), - icon: const Icon(Icons.refresh, size: 18), - label: Text(tr('다시 시도')), + if (e.empty && dels.isNotEmpty) + OutlinedButton( + onPressed: running ? null : () => confirmEmptySourceSync(context, live, x), + child: Text(tr('지울 목록 보고 결정')), ), - if (e.empty && dels.isNotEmpty) - OutlinedButton( - onPressed: running ? null : () => confirmEmptySourceSync(context, live, x), - child: Text(tr('지울 목록 보고 결정')), - ), - ]), - ]), + ], ); } diff --git a/lib/ui/notice_band.dart b/lib/ui/notice_band.dart index 08c380f..1fa4027 100644 --- a/lib/ui/notice_band.dart +++ b/lib/ui/notice_band.dart @@ -1,7 +1,8 @@ /// 약속 (W 공용 위젯, 설계 7-2 NoticeBand): 목록 · 화면 위에 띄우는 안내 띠 하나. -/// 입력: 아이콘 · 제목 · 설명 · 단추들 · 색 (처음 값 경고색). 출력: 연한 바탕 · 테두리 · 둥근 띠 - 제목은 굵게, 설명은 작고 흐리게, +/// 입력: 아이콘 · 제목 · 설명 (없어도 됨, [selectable] 이면 골라 복사) · 단추들 · 색 (처음 값 경고색) · 바깥 여백 ([margin]). 출력: 연한 바탕 · 테두리 · 둥근 띠 - 제목은 굵게, 설명은 작고 흐리게, /// 단추는 넘치면 다음 줄로 (Wrap). 오류 없음. 폭 · 글자 배율 · 언어에 따라 제목 · 설명은 줄을 바꾼다 (넘치지 않음). -/// 쓰는 곳: 파일 탐색기 안내 (권한 · 읽지 못함 · 만들다 만 파일 · 시스템 폴더 · 루트 · 서버 열쇠). 화면은 부르기만 한다 +/// 쓰는 곳: 파일 탐색기 안내 (권한 · 읽지 못함 · 만들다 만 파일 · 시스템 폴더 · 루트 · 서버 열쇠) · 실시간 동기화 멈춤 · 마스터 기다림 · +/// AI 그림 화면 · AI 서비스 · FTP 평문 경고 · 동영상 오류. 화면은 부르기만 한다 library; import 'package:flutter/material.dart'; @@ -9,11 +10,23 @@ 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}); + const NoticeBand( + {super.key, + required this.icon, + required this.title, + this.body, + this.actions = const [], + this.color, + this.selectable = false, + this.margin = const EdgeInsets.fromLTRB(6, 6, 6, 0)}); final IconData icon; final String title; - final String body; + final String? body; + + /// 설명 (설명이 없으면 제목) 을 골라 복사할 수 있게 (오류 원문) + final bool selectable; + final EdgeInsetsGeometry margin; final List actions; /// 띠 색 (처음 값 [JjColors.alert]) @@ -24,7 +37,7 @@ class NoticeBand extends StatelessWidget { final c = color ?? JjColors.alert; return Container( width: double.infinity, - margin: const EdgeInsets.fromLTRB(6, 6, 6, 0), + margin: margin, padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), decoration: BoxDecoration( color: c.withValues(alpha: 0.12), @@ -35,10 +48,17 @@ class NoticeBand extends StatelessWidget { 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))), + Expanded( + child: selectable && body == null + ? SelectableText(title, style: const TextStyle(fontWeight: FontWeight.w600)) + : Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), ]), - const SizedBox(height: 4), - Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + if (body case final b?) ...[ + const SizedBox(height: 4), + selectable + ? SelectableText(b, style: TextStyle(fontSize: 12, color: JjColors.textDim)) + : Text(b, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ], Wrap(spacing: 8, children: actions), ]), ); diff --git a/test/sync_problem_test.dart b/test/sync_problem_test.dart index b477aa3..fff5ee8 100644 --- a/test/sync_problem_test.dart +++ b/test/sync_problem_test.dart @@ -139,7 +139,8 @@ void main() { await tester.tap(find.text('lsync (실시간 동기화)')); await tester.pumpAndSettle(); expect(find.text('원본 폴더가 비어 있어 멈췄습니다'), findsOneWidget); - expect(find.text('대상 파일은 지우지 않았습니다.'), findsOneWidget); + // W NoticeBand: 설명 줄들이 띠의 설명 한 덩어리로 (글은 그대로 보임) + expect(find.textContaining('대상 파일은 지우지 않았습니다.'), findsOneWidget); expect(find.text('맞출 것 없음 (원본과 대상이 같습니다)'), findsNothing); expect(find.text('다시 시도'), findsOneWidget); await tester.tap(find.text('지울 목록 보고 결정'));