app_actions 나누기 (720 → 119): 위쪽 막대 · 화면 이동 · 밀어서 옮기기 · 사용량 · 화면 배율을 lib/ui/app_bar/ 모듈로

- app_actions.dart 는 AppActions (설정 · 종료 버튼) 만 두고 옮긴 것을 그대로 내보낸다 (부르는 곳 그대로)
- 약속 시험: AppNavButtons.homeId · isCompact

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 03:41:35 +09:00
parent 3b3d70fc18
commit e3f8d8dbef
6 changed files with 691 additions and 608 deletions

View File

@ -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<AppTopBar> createState() => _AppTopBarState();
}
class _AppTopBarState extends State<AppTopBar> {
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<AppScope>();
@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<void> 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<AppComponent> 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<void>.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 <AppComponent>[] : 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<SwipeNav> createState() => _SwipeNavState();
}
class _SwipeNavState extends State<SwipeNav> {
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<UsageSample?>(
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 {

View File

@ -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<AppScope>();
@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<void> 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<AppComponent> 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<void>.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 <AppComponent>[] : 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('뒤로')),
]);
}
}

View File

@ -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<AppTopBar> createState() => _AppTopBarState();
}
class _AppTopBarState extends State<AppTopBar> {
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;
}

View File

@ -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<SwipeNav> createState() => _SwipeNavState();
}
class _SwipeNavState extends State<SwipeNav> {
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,
);
}
}

View File

@ -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<UsageSample?>(
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),
),
),
);
});
},
);
}

View File

@ -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 = <bool>[];
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);
});
}