import 'dart:io'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../app/bookmarks_controller.dart'; import '../app/download_manager.dart'; import '../core/charset_detector.dart'; import '../core/encode_options.dart'; import '../core/languages.dart'; import '../core/models.dart'; import '../core/output_paths.dart'; import '../core/playlist.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'browser_page.dart'; import 'downloads_page.dart'; import 'player_page.dart'; import 'subtitle_editor_page.dart'; import 'subtitle_search_dialog.dart'; import 'theme.dart'; import 'translate_dialog.dart'; import 'work_panel.dart'; class HomePage extends StatelessWidget { final AppController c; /// 다운로드 (없으면 "다운로딩" 상자 숨김) final DownloadManager? downloads; /// 종료 버튼 (없으면 숨김) final VoidCallback? onExit; /// 즐겨찾기 (없으면 브라우저 버튼 숨김) final BookmarksController? bookmarks; const HomePage({super.key, required this.c, this.downloads, this.onExit, this.bookmarks}); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: c, builder: (context, _) => Scaffold( // 탐색기에서 동영상 · 폴더를 끌어다 놓으면 동영상만 추가 body: DropTarget( onDragDone: (d) async { final videos = await collectVideos(d.files.map((f) => f.path), isDirectory: (x) => FileSystemEntity.isDirectory(x), listDir: (x) async => Directory(x).list().map((e) => e.path).toList()); if (videos.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('끌어다 놓은 항목에 동영상이 없습니다.'))); } return; } await c.addVideos(videos); }, child: Column( children: [ _TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks), const Divider(height: 1), _EncodeBar(c: c), const Divider(height: 1), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(width: 320, child: _VideoList(c: c)), const VerticalDivider(width: 1), Expanded( child: c.selected == null ? const _EmptyHint() : _VideoDetail(c: c, v: c.selected!), ), ], ), ), const Divider(height: 1), if (c.settings.showLog) SizedBox( height: 140, child: _LogPanel( logs: c.logs, onClose: () => c.updateSettings((s) => s.showLog = false), ), ) else _LogStrip( last: c.logs.isEmpty ? '' : c.logs.last, onOpen: () => c.updateSettings((s) => s.showLog = true), ), ], ), ), ), ); } } // ───────── 상단 바 ───────── class _TopBar extends StatelessWidget { final AppController c; final DownloadManager? downloads; final VoidCallback? onExit; final BookmarksController? bookmarks; const _TopBar({required this.c, this.downloads, this.onExit, this.bookmarks}); static String _batchHint(AppController c, String what) => c.checked.isEmpty ? '지금 보고 있는 동영상의 $what (여러 개는 목록에서 체크)' : '체크한 동영상 ${c.checked.length}개의 $what'; @override Widget build(BuildContext context) { final canBuild = c.videos.isNotEmpty && c.ffmpegVersion != null; // 일괄 작업 대상: 체크한 동영상, 없으면 지금 보고 있는 한 개 final batch = c.batchTargets; final canBatch = batch.isNotEmpty && c.ffmpegVersion != null; final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; return Container( height: appBarHeight, color: JjColors.panel, padding: const EdgeInsets.only(left: 16, right: appBarRightPadding), child: Row( children: [ // 프로그램 아이콘 (tool/make_icon.py 로 생성) Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium), const SizedBox(width: 8), const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), const SizedBox(width: 24), // 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록) Expanded( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: [ OutlinedButton.icon( onPressed: c.pickVideos, icon: const Icon(Icons.add, size: 18), label: const Text('동영상 추가'), ), if (c.aiAvailable) ...[ const SizedBox(width: 8), Tooltip( message: _batchHint(c, 'AI 자막을 만듭니다'), child: OutlinedButton.icon( onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, icon: const Icon(Icons.auto_awesome, size: 18), label: Text('자막 만들기$count'), ), ), const SizedBox(width: 8), Tooltip( message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'), child: OutlinedButton.icon( onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) : null, icon: const Icon(Icons.auto_mode, size: 18), label: Text('자막 만들기 & MKV 만들기$count'), ), ), ], ]), ), ), const SizedBox(width: 8), if (c.busy) ...[ JobIndicator(c: c), const SizedBox(width: 8), OutlinedButton.icon( onPressed: c.cancel, icon: const Icon(Icons.stop, size: 18, color: JjColors.danger), label: const Text('취소'), ), const SizedBox(width: 8), ], // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) if (c.services.createMediaPlayer != null) ...[ Tooltip( message: c.checked.isEmpty ? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)' : '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다', child: OutlinedButton.icon( onPressed: batch.isEmpty ? null : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), icon: const Icon(Icons.playlist_play, size: 20), label: Text('선택한 파일 재생$count'), ), ), const SizedBox(width: 8), ], FilledButton.icon( // 체크한 동영상이 있으면 그것만, 없으면 목록 전체 onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), label: Text('MKV 만들기$count${c.busy ? ' (대기열)' : ''}'), ), if (bookmarks != null) ...[ const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))), icon: const Icon(Icons.public, size: 18), label: const Text('브라우저'), ), ], if (downloads != null) ...[ const SizedBox(width: 12), _DownloadBox(d: downloads!), ], // 환경 설정 · 종료: 모든 화면에서 같은 자리 AppActions(c: c, onExit: onExit), ], ), ); } } /// 상단 "다운로딩" 상자: 누르면 다운로드 목록 class _DownloadBox extends StatelessWidget { final DownloadManager d; const _DownloadBox({required this.d}); @override Widget build(BuildContext context) => ListenableBuilder( listenable: d, builder: (context, _) { final n = d.downloadingCount; final p = d.overallProgress; return InkWell( borderRadius: BorderRadius.circular(6), onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => DownloadsPage(d: d))), child: Container( width: 150, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( border: Border.all(color: n > 0 ? JjColors.accent : JjColors.border), borderRadius: BorderRadius.circular(6), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.downloading, size: 16, color: n > 0 ? JjColors.accent : JjColors.textDim), const SizedBox(width: 6), Text( n > 0 ? '다운로딩 $n${p == null ? '' : ' · ${(p * 100).toStringAsFixed(0)}%'}' : '다운로드 ${d.tasks.length}', style: const TextStyle(fontSize: 12)), ]), if (n > 0) ...[ const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: p, minHeight: 4, backgroundColor: JjColors.panelHigh), ), ], ], ), ), ); }, ); } // ───────── 화면 크기 · 코덱 · 화질 ───────── class _EncodeBar extends StatelessWidget { final AppController c; const _EncodeBar({required this.c}); @override Widget build(BuildContext context) { final s = c.encode; final locked = c.busy; final up = c.upscaleCount; Widget label(String t) => Padding( padding: const EdgeInsets.only(right: 6), child: Text(t, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ); Widget drop(T value, List items, String Function(T) text, ValueChanged onChanged, {bool Function(T)? enabled}) => DropdownButton( value: value, isDense: true, underline: const SizedBox(), style: const TextStyle(fontSize: 13, color: JjColors.text), items: [ for (final i in items) DropdownMenuItem( value: i, enabled: enabled?.call(i) ?? true, child: Text( text(i) + ((enabled?.call(i) ?? true) ? '' : ' (사용 불가)'), style: TextStyle( color: (enabled?.call(i) ?? true) ? null : JjColors.textDim), ), ), ], onChanged: locked ? null : (v) => onChanged(v as T), ); return Container( height: 44, color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(children: [ label('화면 크기'), drop(s.resolution, ResolutionChoice.values, (r) => r.label, c.setResolution), const SizedBox(width: 20), label('코덱'), drop(s.codec, VideoCodecChoice.values, (v) => v.label, c.setCodec, enabled: c.isCodecAvailable), const SizedBox(width: 20), if (s.reencode) ...[ label('화질'), drop(s.quality, QualityChoice.values, (q) => q.label, c.setQuality), const SizedBox(width: 20), ], Expanded( child: Text( !s.reencode ? '영상·음성을 그대로 복사합니다 (빠름, 화질 손실 없음)' : '영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로)' '${up > 0 ? ' · ⚠ $up개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다' : ''}', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim), ), ), ]), ); } } // ───────── 왼쪽: 동영상 목록 ───────── class _VideoList extends StatelessWidget { final AppController c; const _VideoList({required this.c}); /// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표 Widget _sortButton(String by, IconData icon, String name) { final on = c.sortedBy == by; return IconButton( tooltip: '$name 순으로 정렬' '${on ? (c.sortAscending ? ' (지금: 오름차순 · 다시 누르면 내림차순)' : ' (지금: 내림차순 · 다시 누르면 오름차순)') : ''}', iconSize: 18, visualDensity: VisualDensity.compact, onPressed: c.videos.length < 2 ? null : () => c.sortVideos(by), icon: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: on ? JjColors.accent : JjColors.textDim), if (on) Icon(c.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: JjColors.accent), ]), ); } @override Widget build(BuildContext context) { return Container( color: JjColors.panel, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기 Padding( padding: const EdgeInsets.fromLTRB(8, 6, 4, 2), child: Row(children: [ Checkbox( visualDensity: VisualDensity.compact, tristate: true, value: c.videos.isEmpty || c.checked.isEmpty ? false : (c.checked.length == c.videos.length ? true : null), onChanged: c.videos.isEmpty ? null : (_) => c.toggleAllChecked(), ), Expanded( child: InkWell( onTap: c.videos.isEmpty ? null : c.toggleAllChecked, child: Text( c.checked.isEmpty ? '동영상 ${c.videos.length}개 · 전체 선택' : '선택 ${c.checked.length} / ${c.videos.length}개', style: const TextStyle(color: JjColors.textDim, fontSize: 12)), ), ), // 정렬: 같은 것을 다시 누르면 반대 순서 _sortButton('name', Icons.sort_by_alpha, '파일 이름'), _sortButton('date', Icons.calendar_month, '날짜'), if (c.checked.isNotEmpty) IconButton( tooltip: '선택한 동영상을 목록에서 제거', iconSize: 18, visualDensity: VisualDensity.compact, icon: const Icon(Icons.playlist_remove, color: JjColors.textDim), onPressed: c.removeChecked, ), IconButton( tooltip: '모두 지우기', iconSize: 18, visualDensity: VisualDensity.compact, icon: const Icon(Icons.clear_all, color: JjColors.textDim), onPressed: c.busy || c.videos.isEmpty ? null : c.clearVideos, ), ]), ), Expanded( child: ListView.builder( itemCount: c.videos.length, itemBuilder: (_, i) => _VideoTile(c: c, v: c.videos[i]), ), ), ], ), ); } } class _VideoTile extends StatelessWidget { final AppController c; final VideoItem v; const _VideoTile({required this.c, required this.v}); @override Widget build(BuildContext context) { final isSel = c.selected == v; final subs = v.subtitles.where((s) => s.enabled).length; return InkWell( onTap: () => c.select(v), onDoubleTap: () => playFiles(context, c, [v.path]), child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.fromLTRB(2, 10, 4, 10), decoration: BoxDecoration( color: isSel ? JjColors.panelHigh : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSel ? JjColors.accent.withValues(alpha: 0.6) : Colors.transparent), ), child: Row( children: [ // 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기) Checkbox( visualDensity: VisualDensity.compact, value: c.checked.contains(v), onChanged: (_) => c.toggleChecked(v), ), _StatusIcon(v.status), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(v.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13)), const SizedBox(height: 4), if (v.status == JobStatus.running) ...[ LinearProgressIndicator(value: v.progress, minHeight: 3), if (v.phase != null) Padding( padding: const EdgeInsets.only(top: 3), child: Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.accent)), ), ] else if (v.phase != null) Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim)) else Text( v.status == JobStatus.failed ? '실패' : '자막 $subs개', style: TextStyle( fontSize: 11, color: v.status == JobStatus.failed ? JjColors.danger : JjColors.textDim), ), ], ), ), if (v.status == JobStatus.done || v.status == JobStatus.failed) IconButton( tooltip: '다시 만들기 대기로', iconSize: 16, onPressed: () => c.resetStatus(v), icon: const Icon(Icons.refresh, color: JjColors.textDim), ), IconButton( tooltip: '목록에서 제거', iconSize: 16, onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v), icon: const Icon(Icons.close, color: JjColors.textDim), ), ], ), ), ); } } class _StatusIcon extends StatelessWidget { final JobStatus s; const _StatusIcon(this.s); @override Widget build(BuildContext context) => switch (s) { JobStatus.ready => const Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim), JobStatus.running => const Icon(Icons.autorenew, size: 20, color: JjColors.accent), JobStatus.done => const Icon(Icons.check_circle, size: 20, color: JjColors.success), JobStatus.failed => const Icon(Icons.error, size: 20, color: JjColors.danger), }; } // ───────── 오른쪽: 선택한 동영상 ───────── class _EmptyHint extends StatelessWidget { const _EmptyHint(); @override Widget build(BuildContext context) => const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.video_library_outlined, size: 56, color: JjColors.textDim), SizedBox(height: 12), Text('"동영상 추가" 로 파일을 선택하세요', style: TextStyle(color: JjColors.textDim)), SizedBox(height: 4), Text('같은 폴더의 자막은 자동으로 추가됩니다', style: TextStyle(color: JjColors.textDim, fontSize: 12)), ], ), ); } class _VideoDetail extends StatelessWidget { final AppController c; final VideoItem v; const _VideoDetail({required this.c, required this.v}); @override Widget build(BuildContext context) { final info = v.info; final video = info?.ofType('video').firstOrNull; final audioCount = info?.ofType('audio').length ?? 0; // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) final locked = v.status == JobStatus.running; return ListView( padding: const EdgeInsets.all(20), children: [ Row(children: [ Expanded( child: Text(v.fileName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), ), if (c.services.createMediaPlayer != null) ...[ const SizedBox(width: 8), FilledButton.icon( onPressed: () => playFiles(context, c, [v.path]), icon: const Icon(Icons.play_arrow, size: 18), label: const Text('재생'), ), ], ]), const SizedBox(height: 4), SelectableText(v.path, style: const TextStyle(color: JjColors.textDim, fontSize: 12)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ if (info?.duration != null) _Chip('길이 ${_fmt(info!.duration!)}'), if (video != null) ...[ if (video.width != null) _Chip('${video.width}×${video.height}'), _Chip('영상 ${video.codec}'), ], _Chip('음성 $audioCount개'), ]), 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: const TextStyle(color: JjColors.danger, fontSize: 12)), ), ], const SizedBox(height: 24), Row(children: [ const Text('자막', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const Spacer(), if (c.aiAvailable) ...[ FilledButton.icon( onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), icon: const Icon(Icons.auto_awesome, size: 18), label: const Text('AI 자막 만들기'), ), const SizedBox(width: 8), ], if (c.subtitleProvider != null) ...[ OutlinedButton.icon( onPressed: locked ? null : () => showSubtitleSearch(context, c, v), icon: const Icon(Icons.travel_explore, size: 18), label: const Text('인터넷 자막 찾기'), ), const SizedBox(width: 8), ], OutlinedButton.icon( onPressed: locked ? null : () => c.pickSubtitlesFor(v), icon: const Icon(Icons.subtitles_outlined, size: 18), label: const Text('자막 파일 추가'), ), ]), const SizedBox(height: 8), if (v.subtitles.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Text('자막이 없습니다.', style: TextStyle(color: JjColors.textDim)), ), for (final s in v.subtitles) _SubtitleRow(c: c, v: v, s: s, locked: locked), const SizedBox(height: 24), Text('출력: ${outputMkvPath(v.path)}', style: const TextStyle(color: JjColors.textDim, fontSize: 12)), ], ); } static String _fmt(Duration d) { String two(int n) => n.toString().padLeft(2, '0'); return '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; } } class _Chip extends StatelessWidget { final String text; const _Chip(this.text); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: JjColors.panelHigh, borderRadius: BorderRadius.circular(20), ), child: Text(text, style: const TextStyle(fontSize: 12)), ); } class _SubtitleRow extends StatelessWidget { final AppController c; final VideoItem v; final SubtitleEntry s; final bool locked; const _SubtitleRow( {required this.c, required this.v, required this.s, required this.locked}); @override Widget build(BuildContext context) { final embedded = s.kind == SubtitleKind.embedded; final off = !s.enabled; return Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: JjColors.panel, borderRadius: BorderRadius.circular(8), border: Border.all(color: JjColors.border), ), child: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: (embedded ? JjColors.textDim : JjColors.accent) .withValues(alpha: 0.18), borderRadius: BorderRadius.circular(4), ), child: Text(embedded ? '내장' : '외부', style: TextStyle( fontSize: 11, color: embedded ? JjColors.textDim : JjColors.accent)), ), const SizedBox(width: 10), Expanded( child: Text( s.displayName, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, color: off ? JjColors.textDim : JjColors.text, decoration: off ? TextDecoration.lineThrough : null, ), ), ), const SizedBox(width: 8), _LanguageDropdown( value: s.language, onChanged: locked || off ? null : (l) => c.setLanguage(s, l), ), if (!embedded) ...[ const SizedBox(width: 8), _CharsetDropdown( value: s.charset ?? 'UTF-8', onChanged: locked ? null : (cs) => c.setCharset(s, cs), ), ], const SizedBox(width: 4), IconButton( tooltip: c.canEdit(s) ? '내용 편집 · 문자셋 변환' : '이미지 자막은 편집할 수 없습니다', iconSize: 18, onPressed: locked || off || !c.canEdit(s) ? null : () => openSubtitleEditor(context, c, v, s), icon: const Icon(Icons.edit_note, color: JjColors.accent), ), if (c.services.createTranslator != null) IconButton( tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 PC 에서)' : '이미지 자막은 번역할 수 없습니다', iconSize: 18, onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), icon: const Icon(Icons.translate, color: JjColors.accent), ), IconButton( tooltip: embedded ? (off ? '되살리기' : '삭제 (출력에서 제외)') : '목록에서 제거', iconSize: 18, onPressed: locked ? null : () => c.removeSubtitle(v, s), icon: Icon( embedded && off ? Icons.undo : Icons.delete_outline, color: JjColors.textDim, ), ), ], ), ); } } class _LanguageDropdown extends StatelessWidget { final Language value; final ValueChanged? onChanged; const _LanguageDropdown({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final items = [undetermined, ...languages]; return DropdownButton( value: items.contains(value) ? value : undetermined, isDense: true, underline: const SizedBox(), style: const TextStyle(fontSize: 12, color: JjColors.text), items: [ for (final l in items) DropdownMenuItem( value: l, child: Text(l == undetermined ? '언어: 미지정' : '${l.name} (${l.code})'), ), ], onChanged: onChanged == null ? null : (l) => onChanged!(l!), ); } } class _CharsetDropdown extends StatelessWidget { final String value; final ValueChanged? onChanged; const _CharsetDropdown({required this.value, required this.onChanged}); @override Widget build(BuildContext context) => DropdownButton( value: supportedCharsets.containsKey(value) ? value : 'UTF-8', isDense: true, underline: const SizedBox(), style: const TextStyle(fontSize: 12, color: JjColors.text), items: [ for (final e in supportedCharsets.entries) DropdownMenuItem(value: e.key, child: Text(e.value)), ], onChanged: onChanged == null ? null : (v) => onChanged!(v!), ); } // ───────── 아래: 작업 기록 ───────── class _LogPanel extends StatelessWidget { final List logs; final VoidCallback onClose; const _LogPanel({required this.logs, required this.onClose}); @override Widget build(BuildContext context) => Container( color: JjColors.bg, child: Stack(children: [ Positioned.fill( child: ListView.builder( reverse: true, padding: const EdgeInsets.fromLTRB(16, 8, 44, 8), itemCount: logs.length, itemBuilder: (_, i) => SelectableText( logs[logs.length - 1 - i], style: const TextStyle( fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), ), ), ), // 오른쪽 위 ✕: 작업 기록 숨기기 Positioned( top: 2, right: 4, child: IconButton( tooltip: '작업 기록 숨기기', iconSize: 16, visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: JjColors.textDim), onPressed: onClose, ), ), ]), ); } /// 작업 기록을 숨겼을 때 맨 아래 얇은 줄: 마지막 기록 + 다시 보기 class _LogStrip extends StatelessWidget { final String last; final VoidCallback onOpen; const _LogStrip({required this.last, required this.onOpen}); @override Widget build(BuildContext context) => Material( color: JjColors.bg, child: InkWell( onTap: onOpen, child: SizedBox( height: 26, child: Row(children: [ const SizedBox(width: 16), Expanded( child: Text(last, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)), ), const Text('작업 기록 보기', style: TextStyle(fontSize: 11, color: JjColors.accent)), const Icon(Icons.expand_less, size: 16, color: JjColors.accent), const SizedBox(width: 12), ]), ), ), ); }