/// 약속: 화면 이동 - 모든 화면이 쓰는 것 ([AppScope]: 설정 · 종료 · 다운로드 · 즐겨찾기) 과 왼쪽 공통 버튼 ([AppNavButtons]: 홈 · MKV · 뒤로 · /// 다운로드 목록, 켠 화면 차례로 옮기기 · 홈 화면 열기). library; import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/bookmarks_controller.dart'; import '../../app/components.dart'; import '../../app/download_manager.dart'; import '../../app/settings.dart'; import '../../services/app_shell.dart' show appIconButtonAsset; import '../browser_page.dart'; import '../downloads_page.dart'; import '../ai_image_page.dart'; import '../explorer_page.dart'; import '../tor_page.dart'; import '../theme.dart'; import '../../l10n/tr.dart'; import 'app_top_bar.dart'; import '../app_actions.dart'; /// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 class AppScope extends InheritedWidget { final AppController controller; /// 종료 버튼을 눌렀을 때 (없으면 버튼을 숨김) final VoidCallback? onExit; /// 다운로드 목록 · 웹 브라우저 (홈 화면이 브라우저일 때). 새 창 (재생 창) 에서는 없음. final DownloadManager? downloads; final BookmarksController? bookmarks; const AppScope( {super.key, required this.controller, this.onExit, this.downloads, this.bookmarks, required super.child}); static AppScope? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); @override bool updateShouldNotify(AppScope old) => controller != old.controller || onExit != old.onExit || downloads != old.downloads || bookmarks != old.bookmarks; } /// 모든 화면의 위쪽 막대 맨 왼쪽 (같은 자리 · 같은 순서): /// [JJ] 홈 화면 (환경 설정의 "홈 화면": MKV 화면 · 웹 브라우저 · 파일 탐색기) /// [▤] MKV 화면으로 [🌐] 웹 브라우저 [📁] 파일 탐색기 [←] 뒤로 [⇩] 다운로드 목록 class AppNavButtons extends StatelessWidget { /// 다운로드 목록 화면 자신 (그 버튼을 "지금 여기" 로 표시) final bool onDownloadsPage; /// 웹 브라우저 화면 자신 final bool onBrowserPage; /// 파일 탐색기 화면 자신 final bool onExplorerPage; /// Rsync 화면 자신 final bool onRsyncPage; /// AI 그림 화면 자신 (123) final bool onAiPage; /// Tor 브라우저 화면 자신 final bool onTorPage; /// 174: MKV 화면 자신 - 홈 화면이 탐색기 등이면 MKV 화면이 맨 처음 화면이 아닐 수 있어 따로 알린다 final bool onMkvPage; const AppNavButtons( {super.key, this.onDownloadsPage = false, this.onBrowserPage = false, this.onExplorerPage = false, this.onRsyncPage = false, this.onAiPage = false, this.onTorPage = false, this.onMkvPage = false}); /// 버튼 줄의 가장 넓은 폭 (JJ · 화면 6개 · 뒤로) static const double width = 8 * 40; /// MKV 화면 (맨 처음 화면) 까지 돌아가기 static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst); /// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면. static String homeId(AppSettings s) { final want = switch (s.display.startScreen) { 'browser' => 'browser', 'files' => 'explorer', 'tor' => 'tor', _ => 'mkv' }; final pages = AppComponent.pages(s.display.components, s.display.navOrder); if (pages.any((p) => p.id == want)) return want; return pages.isEmpty ? 'mkv' : pages.first.id; } /// 그 화면으로 (이미 열려 있으면 그 화면으로 돌아감). 'mkv' 는 맨 처음 화면. static Future openPage(NavigatorState nav, String id, {required AppController c, DownloadManager? downloads, BookmarksController? bookmarks}) async { switch (id) { case 'mkv': nav.popUntil((r) => r.isFirst); case 'browser': if (bookmarks != null) await BrowserPage.open(nav, c: c, downloads: downloads, bookmarks: bookmarks); case 'explorer': await ExplorerPage.open(nav, c: c); case 'rsync': await ExplorerPage.openRsync(nav, c: c); case 'downloads': if (downloads != null) await DownloadsPage.open(nav, downloads); case 'aiimage': await AiImagePage.open(nav, c: c); case 'tor': await TorPage.open(nav, c: c); } } /// 이 창에서 열 수 있는 화면 (브라우저 · 다운로드가 없는 새 창에서는 그 둘을 뺀다) static List pagesOf(AppScope scope) => [ for (final p in AppComponent.pages(scope.controller.settings.display.components, scope.controller.settings.display.navOrder)) if ((p.id != 'browser' || scope.bookmarks != null) && (p.id != 'downloads' || scope.downloads != null)) p, ]; static void open(BuildContext context, String id) { final scope = AppScope.maybeOf(context); if (scope == null) return; openPage(Navigator.of(context), id, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks); } /// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 다른 화면이면 그 화면을 연다 static void toHome(BuildContext context) { final scope = AppScope.maybeOf(context); final nav = Navigator.of(context); nav.popUntil((r) => r.isFirst); if (scope == null) return; final id = homeId(scope.controller.settings); if (id != 'mkv') open(context, id); } /// 지금 화면에서 [step] 만큼 (−1 이전 · +1 다음) 옮긴 화면으로. 끝 다음은 처음으로. static void step(BuildContext context, String current, int step) { final scope = AppScope.maybeOf(context); if (scope == null) return; final pages = pagesOf(scope); if (pages.length < 2) return; var i = pages.indexWhere((p) => p.id == current); if (i < 0) i = 0; final j = i + step; // 102 · 66: 끝에서 처음으로 돌기를 끄면 끝에서 멈춘다 final wraps = j < 0 || j >= pages.length; if (!scope.controller.settings.display.swipeWrap && wraps) { _hint(context, step > 0 ? tr('마지막 화면입니다') : tr('첫 화면입니다')); return; } final target = pages[j % pages.length]; // 66-2: 민 방향으로 미끄러져 들어오게 (다음 = 오른쪽에서, 이전 = 왼쪽에서). 이미 열린 화면으로 돌아가면 그 화면의 전환대로 NavSlide.pending = step > 0 ? 1 : -1; Future.delayed(const Duration(milliseconds: 600), () => NavSlide.pending = 0); // 66-2: 끝에서 처음으로 (처음에서 끝으로) 넘어갈 때 알린다 - 어디로 갔는지 몰라 헤매지 않게 if (wraps) { _hint(context, step > 0 ? trf('처음으로: {0}', [tr(target.name)]) : trf('마지막으로: {0}', [tr(target.name)])); } open(context, target.id); } static void _hint(BuildContext context, String text) { final m = ScaffoldMessenger.maybeOf(context); m?.hideCurrentSnackBar(); m?.showSnackBar(SnackBar(content: Text(text), duration: const Duration(milliseconds: 1500))); } @override Widget build(BuildContext context) { final scope = AppScope.maybeOf(context); // 컴포넌트 설치 · 순서를 바꾸면 바로 다시 그린다 (이 위젯은 const 로 쓰여 부모가 다시 그려도 그대로이므로) if (scope == null) return _build(context, null); return ListenableBuilder(listenable: scope.controller, builder: (context, _) => _build(context, scope)); } Widget _build(BuildContext context, AppScope? scope) { final nav = Navigator.maybeOf(context); final atRoot = !(nav?.canPop() ?? false); final current = onDownloadsPage ? 'downloads' : onBrowserPage ? 'browser' : onExplorerPage ? 'explorer' : onRsyncPage ? 'rsync' : onAiPage ? 'aiimage' : onTorPage ? 'tor' : onMkvPage || atRoot ? 'mkv' : ''; final pages = scope == null ? const [] : pagesOf(scope); final homeName = switch (scope == null ? 'mkv' : homeId(scope.controller.settings)) { 'browser' => tr('웹 브라우저'), 'explorer' => tr('파일 탐색기'), 'rsync' => 'Rsync', 'downloads' => tr('다운로드 목록'), 'tor' => tr('Tor 브라우저'), _ => tr('MKV 화면'), }; String tip(String id, bool here) => switch (id) { 'mkv' => here ? tr('MKV 화면 (지금 여기)') : tr('MKV 화면으로'), 'browser' => here ? tr('웹 브라우저 (지금 여기)') : tr('웹 브라우저'), 'explorer' => here ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'), 'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync', 'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), 'aiimage' => here ? tr('AI 그림 (지금 여기)') : tr('AI 그림'), 'tor' => here ? tr('Tor 브라우저 (지금 여기)') : tr('Tor 브라우저'), _ => id, }; // 좁은 화면 (폰): 아이콘 아래에 짧은 이름 - 글자 없는 작은 아이콘만 늘어서 무엇인지 몰랐던 것 final labels = isCompact(context); // 지금 화면 버튼 아래 밑줄 (색만으로는 알아보기 어려운 사람도 어디인지 알게) Widget underline(Widget child, bool here) => !here ? child : Stack(clipBehavior: Clip.none, children: [ child, Positioned( left: 10, right: 10, bottom: 0, child: Container( key: const ValueKey('nav-here-underline'), height: 3, decoration: BoxDecoration(color: JjColors.accent, borderRadius: BorderRadius.circular(2))), ), ]); Widget btn0(Widget icon, String tip, VoidCallback? f, {bool here = false, String label = ''}) => labels ? Tooltip( message: tip, child: InkWell( onTap: f, borderRadius: BorderRadius.circular(8), // 66-2: 지금 화면 아이콘은 파랗게 · 뒤에 옅은 바탕 child: Container( decoration: here ? BoxDecoration(color: JjColors.accent.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(8)) : null, width: 54, height: 46, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ IconTheme( data: IconThemeData( size: 20, color: here ? JjColors.accent : (f == null ? JjColors.textDim : JjColors.text)), child: icon, ), const SizedBox(height: 2), // 253: 글자가 크면 칸 (높이 46) 아래로 넘쳤다 - 남는 높이에 맞게 줄인다 Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text(label, maxLines: 1, overflow: TextOverflow.clip, style: TextStyle(fontSize: 10, color: here ? JjColors.accent : JjColors.textDim)), ), ), ]), ), ), ) : SizedBox( width: 40, height: 40, child: IconButton( tooltip: tip, padding: EdgeInsets.zero, isSelected: here, // 66-2: 지금 화면 아이콘은 파랗게 · 뒤에 옅은 바탕 (넓은 화면에서도 어디인지 보이게) style: here ? IconButton.styleFrom( foregroundColor: JjColors.accent, backgroundColor: JjColors.accent.withValues(alpha: 0.16)) : null, icon: icon, onPressed: f, ), ); Widget btn(Widget icon, String tip, VoidCallback? f, {bool here = false, String label = ''}) => underline(btn0(icon, tip, f, here: here, label: label), here); String short(String id) => switch (id) { 'mkv' => 'MKV', 'browser' => tr('브라우저'), 'explorer' => tr('탐색기'), 'rsync' => 'Rsync', 'downloads' => tr('다운로드'), 'aiimage' => tr('AI 그림'), 'tor' => 'Tor', _ => id, }; return Row(mainAxisSize: MainAxisSize.min, children: [ btn( Image.asset(appIconButtonAsset(scope?.controller.settings.display.appIcon), width: labels ? 22 : 26, height: labels ? 22 : 26, filterQuality: FilterQuality.medium), trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]), scope == null ? null : () => toHome(context), label: tr('홈'), ), // 설치한 화면들 (환경 설정 > 컴포넌트 의 순서). 하나뿐이면 버튼 줄은 그것만. for (final p in pages) btn( Icon(p.icon, color: p.id == current ? JjColors.accent : null), tip(p.id, p.id == current), p.id == current ? null : () => open(context, p.id), here: p.id == current, label: short(p.id), ), btn(const Icon(Icons.arrow_back), tr('뒤로'), // 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다 // 위쪽 ← 는 늘 화면 닫기 (브라우저 · 탐색기는 뒤로 키가 웹 뒤로 · 선택 풀기 · 상위 폴더를 먼저 하므로 그것을 거치지 않게 - 49) atRoot ? null : () => onBrowserPage || onTorPage || onExplorerPage || onRsyncPage ? Navigator.pop(context) : Navigator.maybePop(context), label: tr('뒤로')), ]); } }