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('지울 목록 보고 결정'));