From e3f8d8dbef09747097b5808fe7c9524f2f630a49 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:41:35 +0900 Subject: [PATCH] =?UTF-8?q?app=5Factions=20=EB=82=98=EB=88=84=EA=B8=B0=20(?= =?UTF-8?q?720=20=E2=86=92=20119):=20=EC=9C=84=EC=AA=BD=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=C2=B7=20=EB=B0=80=EC=96=B4=EC=84=9C=20=EC=98=AE=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B0=B0=EC=9C=A8=EC=9D=84=20lib/ui/app?= =?UTF-8?q?=5Fbar/=20=EB=AA=A8=EB=93=88=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_actions.dart 는 AppActions (설정 · 종료 버튼) 만 두고 옮긴 것을 그대로 내보낸다 (부르는 곳 그대로) - 약속 시험: AppNavButtons.homeId · isCompact Co-Authored-By: Claude Opus 5.5 --- lib/ui/app_actions.dart | 615 +----------------------- lib/ui/app_bar/app_nav_buttons.dart | 314 ++++++++++++ lib/ui/app_bar/app_top_bar.dart | 133 +++++ lib/ui/app_bar/swipe_nav.dart | 45 ++ lib/ui/app_bar/usage_view.dart | 153 ++++++ test/app_bar_modules_contract_test.dart | 39 ++ 6 files changed, 691 insertions(+), 608 deletions(-) create mode 100644 lib/ui/app_bar/app_nav_buttons.dart create mode 100644 lib/ui/app_bar/app_top_bar.dart create mode 100644 lib/ui/app_bar/swipe_nav.dart create mode 100644 lib/ui/app_bar/usage_view.dart create mode 100644 test/app_bar_modules_contract_test.dart diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index ec10c3f..34bdc33 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -1,619 +1,18 @@ 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 '../services/system_usage.dart'; -import 'browser_page.dart'; -import 'downloads_page.dart'; -import 'ai_image_page.dart'; -import 'explorer_page.dart'; import 'settings_page.dart'; -import 'tor_page.dart'; import 'theme.dart'; import '../l10n/tr.dart'; +import 'app_bar/app_top_bar.dart'; +import 'app_bar/app_nav_buttons.dart'; +import 'app_bar/usage_view.dart'; -/// 모든 화면의 위쪽 막대 높이 · 오른쪽 여백 (환경 설정 · 종료 버튼이 어느 화면에서나 같은 자리에 오도록) -const double appBarHeight = 56; -const double appBarRightPadding = 12; - -/// 이보다 좁으면 (접은 폴드 · 휴대폰 세로 등) 위쪽 막대를 두 줄로: 위 = 화면 이동 · 설정 · 종료, 아래 = 화면의 도구 -const double compactWidth = 720; -bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compactWidth; - -/// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions]. -/// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다. -/// [minMiddleWidth] (201): 한 줄일 때 middle 이 이보다 좁아지면 (폰 가로처럼 넓지만 버튼이 폭을 다 차지할 때) 두 줄로. -/// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함). -/// [trailing] (242): middle 오른쪽에 붙는, 줄지 않는 버튼 묶음 - 이것 때문에 middle 이 [minMiddleWidth] 보다 좁아지면 두 줄 -/// (어림한 폭이 실제보다 작아 넘친 버튼이 화면 크기 "100%" 를 덮던 것 - 실제로 재어 정한다) -class AppTopBar extends StatefulWidget { - final Widget nav; - final Widget middle; - final Widget? trailing; - final Widget actions; - final double minMiddleWidth; - const AppTopBar( - {super.key, - required this.nav, - required this.middle, - this.trailing, - required this.actions, - this.minMiddleWidth = 0}); - - @override - State createState() => _AppTopBarState(); -} - -class _AppTopBarState extends State { - final _mid = GlobalKey(); - - /// 한 줄에서 middle 이 모자랐던 화면 폭 - 이 폭 이하이면 두 줄 - double? _twoRowsAtOrBelow; - - void _check(double width) { - if (!mounted || widget.minMiddleWidth <= 0) return; - final box = _mid.currentContext?.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) return; - if (box.size.width < widget.minMiddleWidth) setState(() => _twoRowsAtOrBelow = width); - } - - @override - Widget build(BuildContext context) { - final nav = widget.nav, middle = widget.middle, actions = widget.actions; - final width = MediaQuery.sizeOf(context).width; - final narrow = _twoRowsAtOrBelow != null && width <= _twoRowsAtOrBelow!; - if (!isCompact(context) && !narrow) { - if (widget.minMiddleWidth > 0) WidgetsBinding.instance.addPostFrameCallback((_) => _check(width)); - return Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게. - // trailing 이 있으면 제 폭대로 재고 남는 폭을 middle 에 (모자라면 [_check] 가 두 줄로 - 그사이 넘침 오류 없이 잘라 그림) - child: widget.trailing == null - ? Row(children: [ - nav, - const SizedBox(width: 8), - Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), - actions, - ]) - : ClipRect( - child: CustomMultiChildLayout(delegate: _BarLayout(), children: [ - LayoutId(id: #nav, child: nav), - LayoutId(id: #mid, child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), - LayoutId(id: #trail, child: widget.trailing!), - LayoutId(id: #act, child: actions), - ]), - ), - ); - } - return Container( - color: JjColors.panel, - padding: const EdgeInsets.only(left: 4, right: 4), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 아주 좁으면 왼쪽 이동 버튼 묶음을 줄여서라도 설정 · 종료가 밀려나지 않게 - SizedBox( - height: 48, - child: Row(children: [ - Expanded(child: Align(alignment: Alignment.centerLeft, child: FittedBox(fit: BoxFit.scaleDown, child: nav))), - actions, - ]), - ), - // F8 (10/21): 아래 줄은 48 이상 - middle 이 좁아 두 줄로 바꾸면 (다운로드 화면의 주소 칸) 그만큼 늘어난다. 한 줄이면 예전처럼 48 가운데 - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Align( - alignment: Alignment.centerLeft, - heightFactor: 1, - child: SizedBox( - width: double.infinity, // 아래 줄은 가운데가 폭을 다 쓴다 (201) - child: Padding( - padding: const EdgeInsets.only(left: 6), - child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))))), - ]), - ); - } -} - -/// 242: [nav] [middle (남는 폭)] [trailing] [actions] - nav · trailing · actions 는 제 폭대로, 세로 가운데 -class _BarLayout extends MultiChildLayoutDelegate { - @override - void performLayout(Size size) { - final loose = BoxConstraints(maxHeight: size.height); - final nav = layoutChild(#nav, loose); - final act = layoutChild(#act, loose); - final trail = layoutChild(#trail, loose); - final midW = (size.width - nav.width - 8 - trail.width - act.width).clamp(0.0, double.infinity); - final mid = layoutChild(#mid, BoxConstraints(minWidth: midW, maxWidth: midW, maxHeight: size.height)); - double y(Size s) => (size.height - s.height) / 2; - positionChild(#nav, Offset(0, y(nav))); - positionChild(#mid, Offset(nav.width + 8, y(mid))); - positionChild(#trail, Offset(nav.width + 8 + midW, y(trail))); - positionChild(#act, Offset(size.width - act.width, y(act))); - } - - @override - bool shouldRelayout(_BarLayout oldDelegate) => false; -} - -/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 -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('뒤로')), - ]); - } -} - -/// 화면 가운데를 좌우로 밀면 다음 · 이전 화면으로 (환경 설정 > 컴포넌트 의 순서, 끝 다음은 처음). -/// 안쪽의 옆으로 밀리는 것 (가로 목록 · 막대) 이 먼저 받고, 위 · 아래 가장자리 (1/5) 에서 시작한 것은 무시한다. -class SwipeNav extends StatefulWidget { - final String current; - final Widget child; - - /// 높이 어디서 시작해도 (위쪽 막대처럼 작은 곳에 씌울 때) - final bool anywhere; - const SwipeNav({super.key, required this.current, required this.child, this.anywhere = false}); - - @override - State createState() => _SwipeNavState(); -} - -class _SwipeNavState extends State { - bool _middle = false; - - @override - Widget build(BuildContext context) { - final scope = AppScope.maybeOf(context); - if (scope == null) return widget.child; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (d) { - final h = context.size?.height ?? 0; - _middle = widget.anywhere || (h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8); - }, - onHorizontalDragEnd: (d) { - final v = d.primaryVelocity ?? 0; - // 켜고 끄기는 밀 때 확인 (이 위젯은 설정이 바뀌어도 다시 그려지지 않을 수 있음) - if (!scope.controller.settings.display.swipeNav || !_middle || v.abs() < 500) return; - // 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 - AppNavButtons.step(context, widget.current, v < 0 ? 1 : -1); - }, - child: widget.child, - ); - } -} - -/// "CPU 23% MEM 61%" (90% 넘으면 빨갛게). 글자 폭이 바뀌어도 옆 버튼이 움직이지 않게 너비를 고정한다. -class UsageView extends StatelessWidget { - final UsageMonitor usage; - const UsageView({super.key, required this.usage}); - - static String _gb(int bytes) => (bytes / (1024 * 1024 * 1024)).toStringAsFixed(1); - - /// 남은 용량: "128GB" · "1.8TB" · "950MB" - static String _size(int bytes) { - const gb = 1024 * 1024 * 1024; - if (bytes >= 1000 * gb) return '${(bytes / (1024 * gb)).toStringAsFixed(1)}TB'; - if (bytes >= gb) return '${(bytes / gb).toStringAsFixed(bytes < 10 * gb ? 1 : 0)}GB'; - return '${(bytes / (1024 * 1024)).round()}MB'; - } - - @override - Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: usage, - builder: (context, s, _) { - Widget cell(String label, String value, bool hot, double width) { - return SizedBox( - width: width, - child: Text.rich( - TextSpan(children: [ - TextSpan(text: '$label ', style: TextStyle(color: JjColors.textDim)), - TextSpan( - text: value, - style: TextStyle( - color: hot ? JjColors.danger : JjColors.text, fontWeight: FontWeight.w600)), - ]), - maxLines: 1, - softWrap: false, - style: const TextStyle(fontSize: 11, fontFamily: 'Consolas'), - ), - ); - } - - Widget item(String label, double? v) => - cell(label, v == null ? '--%' : '${(v * 100).round()}%', v != null && v >= 0.9, 62); - final free = s?.diskFree; - - return Tooltip( - message: s == null - ? tr('PC 전체 CPU · 메모리 사용량') - : trf('PC 전체 사용량\nCPU {0}%\n' '메모리 {1} / {2}GB ({3}%)' '{4}', [(s.cpu * 100).round(), _gb(s.memUsed), _gb(s.memTotal), (s.mem * 100).round(), free == null ? '' : trf('\n다운로드 디스크 {0} 남은 용량 {1}' '{2}', [s.disk, _size(free), s.diskTotal == null ? '' : trf(' / 전체 {0}', [_size(s.diskTotal!)])])]), - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - item('CPU', s?.cpu), - item('MEM', s?.mem), - // 다운로드 대상 디스크의 남은 용량 - if (free != null) cell('DISK', _size(free), s!.diskLow, 84), - ]), - ), - ); - }, - ); -} - -/// 화면 크기 조절: [−] [100%] [+]. 10% 씩 바꾸고, 가운데 숫자를 누르면 환경 설정의 기본 크기로. -class ScaleButtons extends StatelessWidget { - final AppController c; - const ScaleButtons({super.key, required this.c}); - - void _set(double v) => c.updateSettings((s) => s.display.scale = DisplaySettings.clampScale(v)); - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: c, - builder: (context, _) { - final s = c.settings.display.scale; - Widget btn(IconData icon, String tip, VoidCallback? f) => SizedBox( - width: 26, - height: 32, - child: IconButton( - tooltip: tip, - padding: EdgeInsets.zero, - iconSize: 16, - icon: Icon(icon), - onPressed: f, - ), - ); - return Padding( - padding: const EdgeInsets.only(left: 6), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - btn(Icons.remove, tr('화면 작게'), s <= DisplaySettings.scaleMin + 0.001 ? null : () => _set(s - 0.1)), - Tooltip( - message: trf('기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서', [(c.settings.display.scaleDefault * 100).round()]), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _set(c.settings.display.scaleDefault), - child: SizedBox( - width: 40, - height: 28, - child: Center( - child: Text('${(s * 100).round()}%', - style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', fontWeight: FontWeight.w600)), - ), - ), - ), - ), - btn(Icons.add, tr('화면 크게'), s >= DisplaySettings.scaleMax - 0.001 ? null : () => _set(s + 0.1)), - ]), - ); - }, - ); -} - -/// 화면 전체를 [AppSettings.display.scale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치). -/// 앱의 builder 에서 한 번 감싼다 - 대화 상자 · 메뉴 · 안내 글까지 모두 같은 배율. -class UiScaler extends StatelessWidget { - final AppController c; - final Widget child; - const UiScaler({super.key, required this.c, required this.child}); - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: c, - builder: (context, _) { - final s = c.settings.display.scale; - if ((s - 1).abs() < 0.001) return child; - return LayoutBuilder(builder: (context, box) { - final size = box.biggest / s; - final mq = MediaQuery.of(context); - return ClipRect( - child: OverflowBox( - alignment: Alignment.topLeft, - minWidth: size.width, - maxWidth: size.width, - minHeight: size.height, - maxHeight: size.height, - child: Transform.scale( - scale: s, - alignment: Alignment.topLeft, - child: MediaQuery(data: mq.copyWith(size: size), child: child), - ), - ), - ); - }); - }, - ); -} +export 'app_bar/app_nav_buttons.dart'; +export 'app_bar/app_top_bar.dart'; +export 'app_bar/swipe_nav.dart'; +export 'app_bar/usage_view.dart'; /// 환경 설정 · 종료 버튼. 모든 화면의 위쪽 막대 맨 오른쪽에 같은 크기로 놓는다. class AppActions extends StatelessWidget { diff --git a/lib/ui/app_bar/app_nav_buttons.dart b/lib/ui/app_bar/app_nav_buttons.dart new file mode 100644 index 0000000..80f8b92 --- /dev/null +++ b/lib/ui/app_bar/app_nav_buttons.dart @@ -0,0 +1,314 @@ +/// 약속: 화면 이동 - 모든 화면이 쓰는 것 ([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('뒤로')), + ]); + } +} diff --git a/lib/ui/app_bar/app_top_bar.dart b/lib/ui/app_bar/app_top_bar.dart new file mode 100644 index 0000000..fc1e6d3 --- /dev/null +++ b/lib/ui/app_bar/app_top_bar.dart @@ -0,0 +1,133 @@ +/// 약속: 모든 화면의 위쪽 막대 ([AppTopBar]) - 높이 · 오른쪽 여백이 어느 화면에서나 같고, 좁으면 (또는 가운데가 [AppTopBar.minMiddleWidth] +/// 보다 좁아지면) 두 줄. 좁은 화면 기준 [compactWidth] · [isCompact]. +library; + +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 모든 화면의 위쪽 막대 높이 · 오른쪽 여백 (환경 설정 · 종료 버튼이 어느 화면에서나 같은 자리에 오도록) +const double appBarHeight = 56; + +const double appBarRightPadding = 12; + +/// 이보다 좁으면 (접은 폴드 · 휴대폰 세로 등) 위쪽 막대를 두 줄로: 위 = 화면 이동 · 설정 · 종료, 아래 = 화면의 도구 +const double compactWidth = 720; + +bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compactWidth; + +/// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions]. +/// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다. +/// [minMiddleWidth] (201): 한 줄일 때 middle 이 이보다 좁아지면 (폰 가로처럼 넓지만 버튼이 폭을 다 차지할 때) 두 줄로. +/// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함). +/// [trailing] (242): middle 오른쪽에 붙는, 줄지 않는 버튼 묶음 - 이것 때문에 middle 이 [minMiddleWidth] 보다 좁아지면 두 줄 +/// (어림한 폭이 실제보다 작아 넘친 버튼이 화면 크기 "100%" 를 덮던 것 - 실제로 재어 정한다) +class AppTopBar extends StatefulWidget { + final Widget nav; + final Widget middle; + final Widget? trailing; + final Widget actions; + final double minMiddleWidth; + const AppTopBar( + {super.key, + required this.nav, + required this.middle, + this.trailing, + required this.actions, + this.minMiddleWidth = 0}); + + @override + State createState() => _AppTopBarState(); +} + +class _AppTopBarState extends State { + final _mid = GlobalKey(); + + /// 한 줄에서 middle 이 모자랐던 화면 폭 - 이 폭 이하이면 두 줄 + double? _twoRowsAtOrBelow; + + void _check(double width) { + if (!mounted || widget.minMiddleWidth <= 0) return; + final box = _mid.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + if (box.size.width < widget.minMiddleWidth) setState(() => _twoRowsAtOrBelow = width); + } + + @override + Widget build(BuildContext context) { + final nav = widget.nav, middle = widget.middle, actions = widget.actions; + final width = MediaQuery.sizeOf(context).width; + final narrow = _twoRowsAtOrBelow != null && width <= _twoRowsAtOrBelow!; + if (!isCompact(context) && !narrow) { + if (widget.minMiddleWidth > 0) WidgetsBinding.instance.addPostFrameCallback((_) => _check(width)); + return Container( + height: appBarHeight, + color: JjColors.panel, + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), + // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게. + // trailing 이 있으면 제 폭대로 재고 남는 폭을 middle 에 (모자라면 [_check] 가 두 줄로 - 그사이 넘침 오류 없이 잘라 그림) + child: widget.trailing == null + ? Row(children: [ + nav, + const SizedBox(width: 8), + Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), + actions, + ]) + : ClipRect( + child: CustomMultiChildLayout(delegate: _BarLayout(), children: [ + LayoutId(id: #nav, child: nav), + LayoutId(id: #mid, child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), + LayoutId(id: #trail, child: widget.trailing!), + LayoutId(id: #act, child: actions), + ]), + ), + ); + } + return Container( + color: JjColors.panel, + padding: const EdgeInsets.only(left: 4, right: 4), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 아주 좁으면 왼쪽 이동 버튼 묶음을 줄여서라도 설정 · 종료가 밀려나지 않게 + SizedBox( + height: 48, + child: Row(children: [ + Expanded(child: Align(alignment: Alignment.centerLeft, child: FittedBox(fit: BoxFit.scaleDown, child: nav))), + actions, + ]), + ), + // F8 (10/21): 아래 줄은 48 이상 - middle 이 좁아 두 줄로 바꾸면 (다운로드 화면의 주소 칸) 그만큼 늘어난다. 한 줄이면 예전처럼 48 가운데 + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Align( + alignment: Alignment.centerLeft, + heightFactor: 1, + child: SizedBox( + width: double.infinity, // 아래 줄은 가운데가 폭을 다 쓴다 (201) + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))))), + ]), + ); + } +} + +/// 242: [nav] [middle (남는 폭)] [trailing] [actions] - nav · trailing · actions 는 제 폭대로, 세로 가운데 +class _BarLayout extends MultiChildLayoutDelegate { + @override + void performLayout(Size size) { + final loose = BoxConstraints(maxHeight: size.height); + final nav = layoutChild(#nav, loose); + final act = layoutChild(#act, loose); + final trail = layoutChild(#trail, loose); + final midW = (size.width - nav.width - 8 - trail.width - act.width).clamp(0.0, double.infinity); + final mid = layoutChild(#mid, BoxConstraints(minWidth: midW, maxWidth: midW, maxHeight: size.height)); + double y(Size s) => (size.height - s.height) / 2; + positionChild(#nav, Offset(0, y(nav))); + positionChild(#mid, Offset(nav.width + 8, y(mid))); + positionChild(#trail, Offset(nav.width + 8 + midW, y(trail))); + positionChild(#act, Offset(size.width - act.width, y(act))); + } + + @override + bool shouldRelayout(_BarLayout oldDelegate) => false; +} diff --git a/lib/ui/app_bar/swipe_nav.dart b/lib/ui/app_bar/swipe_nav.dart new file mode 100644 index 0000000..dd32da3 --- /dev/null +++ b/lib/ui/app_bar/swipe_nav.dart @@ -0,0 +1,45 @@ +/// 약속: 좌우로 밀어 다른 화면으로 옮기기 ([SwipeNav]) - 환경 설정 "밀어서 화면 이동" 을 따른다. +library; + +import 'package:flutter/material.dart'; + +import 'app_nav_buttons.dart'; + +/// 화면 가운데를 좌우로 밀면 다음 · 이전 화면으로 (환경 설정 > 컴포넌트 의 순서, 끝 다음은 처음). +/// 안쪽의 옆으로 밀리는 것 (가로 목록 · 막대) 이 먼저 받고, 위 · 아래 가장자리 (1/5) 에서 시작한 것은 무시한다. +class SwipeNav extends StatefulWidget { + final String current; + final Widget child; + + /// 높이 어디서 시작해도 (위쪽 막대처럼 작은 곳에 씌울 때) + final bool anywhere; + const SwipeNav({super.key, required this.current, required this.child, this.anywhere = false}); + + @override + State createState() => _SwipeNavState(); +} + +class _SwipeNavState extends State { + bool _middle = false; + + @override + Widget build(BuildContext context) { + final scope = AppScope.maybeOf(context); + if (scope == null) return widget.child; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (d) { + final h = context.size?.height ?? 0; + _middle = widget.anywhere || (h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8); + }, + onHorizontalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + // 켜고 끄기는 밀 때 확인 (이 위젯은 설정이 바뀌어도 다시 그려지지 않을 수 있음) + if (!scope.controller.settings.display.swipeNav || !_middle || v.abs() < 500) return; + // 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 + AppNavButtons.step(context, widget.current, v < 0 ? 1 : -1); + }, + child: widget.child, + ); + } +} diff --git a/lib/ui/app_bar/usage_view.dart b/lib/ui/app_bar/usage_view.dart new file mode 100644 index 0000000..95a4cc7 --- /dev/null +++ b/lib/ui/app_bar/usage_view.dart @@ -0,0 +1,153 @@ +/// 약속: 위쪽 막대의 CPU · 메모리 · 디스크 표시 ([UsageView]) · 화면 크기 버튼 ([ScaleButtons]) · 화면 전체 배율 ([UiScaler]). +library; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/settings.dart'; +import '../../services/system_usage.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; + +/// "CPU 23% MEM 61%" (90% 넘으면 빨갛게). 글자 폭이 바뀌어도 옆 버튼이 움직이지 않게 너비를 고정한다. +class UsageView extends StatelessWidget { + final UsageMonitor usage; + const UsageView({super.key, required this.usage}); + + static String _gb(int bytes) => (bytes / (1024 * 1024 * 1024)).toStringAsFixed(1); + + /// 남은 용량: "128GB" · "1.8TB" · "950MB" + static String _size(int bytes) { + const gb = 1024 * 1024 * 1024; + if (bytes >= 1000 * gb) return '${(bytes / (1024 * gb)).toStringAsFixed(1)}TB'; + if (bytes >= gb) return '${(bytes / gb).toStringAsFixed(bytes < 10 * gb ? 1 : 0)}GB'; + return '${(bytes / (1024 * 1024)).round()}MB'; + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: usage, + builder: (context, s, _) { + Widget cell(String label, String value, bool hot, double width) { + return SizedBox( + width: width, + child: Text.rich( + TextSpan(children: [ + TextSpan(text: '$label ', style: TextStyle(color: JjColors.textDim)), + TextSpan( + text: value, + style: TextStyle( + color: hot ? JjColors.danger : JjColors.text, fontWeight: FontWeight.w600)), + ]), + maxLines: 1, + softWrap: false, + style: const TextStyle(fontSize: 11, fontFamily: 'Consolas'), + ), + ); + } + + Widget item(String label, double? v) => + cell(label, v == null ? '--%' : '${(v * 100).round()}%', v != null && v >= 0.9, 62); + final free = s?.diskFree; + + return Tooltip( + message: s == null + ? tr('PC 전체 CPU · 메모리 사용량') + : trf('PC 전체 사용량\nCPU {0}%\n' '메모리 {1} / {2}GB ({3}%)' '{4}', [(s.cpu * 100).round(), _gb(s.memUsed), _gb(s.memTotal), (s.mem * 100).round(), free == null ? '' : trf('\n다운로드 디스크 {0} 남은 용량 {1}' '{2}', [s.disk, _size(free), s.diskTotal == null ? '' : trf(' / 전체 {0}', [_size(s.diskTotal!)])])]), + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + item('CPU', s?.cpu), + item('MEM', s?.mem), + // 다운로드 대상 디스크의 남은 용량 + if (free != null) cell('DISK', _size(free), s!.diskLow, 84), + ]), + ), + ); + }, + ); +} + +/// 화면 크기 조절: [−] [100%] [+]. 10% 씩 바꾸고, 가운데 숫자를 누르면 환경 설정의 기본 크기로. +class ScaleButtons extends StatelessWidget { + final AppController c; + const ScaleButtons({super.key, required this.c}); + + void _set(double v) => c.updateSettings((s) => s.display.scale = DisplaySettings.clampScale(v)); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: c, + builder: (context, _) { + final s = c.settings.display.scale; + Widget btn(IconData icon, String tip, VoidCallback? f) => SizedBox( + width: 26, + height: 32, + child: IconButton( + tooltip: tip, + padding: EdgeInsets.zero, + iconSize: 16, + icon: Icon(icon), + onPressed: f, + ), + ); + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + btn(Icons.remove, tr('화면 작게'), s <= DisplaySettings.scaleMin + 0.001 ? null : () => _set(s - 0.1)), + Tooltip( + message: trf('기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서', [(c.settings.display.scaleDefault * 100).round()]), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _set(c.settings.display.scaleDefault), + child: SizedBox( + width: 40, + height: 28, + child: Center( + child: Text('${(s * 100).round()}%', + style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', fontWeight: FontWeight.w600)), + ), + ), + ), + ), + btn(Icons.add, tr('화면 크게'), s >= DisplaySettings.scaleMax - 0.001 ? null : () => _set(s + 0.1)), + ]), + ); + }, + ); +} + +/// 화면 전체를 [AppSettings.display.scale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치). +/// 앱의 builder 에서 한 번 감싼다 - 대화 상자 · 메뉴 · 안내 글까지 모두 같은 배율. +class UiScaler extends StatelessWidget { + final AppController c; + final Widget child; + const UiScaler({super.key, required this.c, required this.child}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: c, + builder: (context, _) { + final s = c.settings.display.scale; + if ((s - 1).abs() < 0.001) return child; + return LayoutBuilder(builder: (context, box) { + final size = box.biggest / s; + final mq = MediaQuery.of(context); + return ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: size.width, + maxWidth: size.width, + minHeight: size.height, + maxHeight: size.height, + child: Transform.scale( + scale: s, + alignment: Alignment.topLeft, + child: MediaQuery(data: mq.copyWith(size: size), child: child), + ), + ), + ); + }); + }, + ); +} diff --git a/test/app_bar_modules_contract_test.dart b/test/app_bar_modules_contract_test.dart new file mode 100644 index 0000000..b143e33 --- /dev/null +++ b/test/app_bar_modules_contract_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/ui/app_bar/app_nav_buttons.dart'; +import 'package:jj_mkvmaker/ui/app_bar/app_top_bar.dart'; + +/// app_actions 를 나눈 모듈의 약속 (실제 설정 · 실제 위젯으로) +void main() { + test('약속 AppNavButtons.homeId: 시작 화면이 켜져 있으면 그 화면, 꺼졌으면 켠 화면의 첫째 (순서대로), 모르는 값은 MKV', () { + final s = AppSettings() + ..display.components = ['mkv', 'browser', 'explorer'] + ..display.navOrder = const []; + s.display.startScreen = 'files'; + expect(AppNavButtons.homeId(s), 'explorer'); + s.display.startScreen = 'tor'; + expect(AppNavButtons.homeId(s), 'mkv', reason: 'Tor 를 끄면 켠 화면의 첫째'); + s.display.components = ['browser', 'explorer']; + expect(AppNavButtons.homeId(s), 'browser', reason: 'MKV 도 끄면 다음 첫째'); + s.display.startScreen = 'nothing'; + expect(AppNavButtons.homeId(s), 'browser'); + s.display.components = []; + expect(AppNavButtons.homeId(s), 'mkv', reason: '켠 화면이 없으면 MKV'); + }); + + testWidgets('약속 isCompact: 폭 720 아래만 좁은 화면 (두 줄 막대)', (t) async { + final got = []; + for (final w in [719.0, 720.0]) { + t.view.physicalSize = Size(w, 600); + t.view.devicePixelRatio = 1; + await t.pumpWidget(MaterialApp(home: Builder(builder: (c) { + got.add(isCompact(c)); + return const SizedBox(); + }))); + } + t.view.reset(); + expect(got, [true, false]); + expect(compactWidth, 720); + }); +}