JJ_MKVMaker/lib/ui/app_actions.dart

465 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 'explorer_page.dart';
import 'settings_page.dart';
import 'theme.dart';
import '../l10n/tr.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] 이 한 줄을 다 쓴다.
class AppTopBar extends StatelessWidget {
final Widget nav;
final Widget middle;
final Widget actions;
const AppTopBar({super.key, required this.nav, required this.middle, required this.actions});
@override
Widget build(BuildContext context) {
if (!isCompact(context)) {
return Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [nav, const SizedBox(width: 8), Expanded(child: middle), 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,
]),
),
SizedBox(height: 48, child: Padding(padding: const EdgeInsets.only(left: 6), child: middle)),
]),
);
}
}
/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다
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;
const AppNavButtons(
{super.key,
this.onDownloadsPage = false,
this.onBrowserPage = false,
this.onExplorerPage = false,
this.onRsyncPage = false});
/// 버튼 줄의 가장 넓은 폭 (JJ · 화면 5개 · 뒤로)
static const double width = 7 * 40;
/// MKV 화면 (맨 처음 화면) 까지 돌아가기
static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst);
/// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면.
static String homeId(AppSettings s) {
final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', _ => 'mkv' };
final pages = AppComponent.pages(s.components, s.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);
}
}
/// 이 창에서 열 수 있는 화면 (브라우저 · 다운로드가 없는 새 창에서는 그 둘을 뺀다)
static List<AppComponent> pagesOf(AppScope scope) => [
for (final p in AppComponent.pages(scope.controller.settings.components, scope.controller.settings.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;
open(context, pages[(i + step) % pages.length].id);
}
@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'
: 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('다운로드 목록'),
_ => 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('다운로드 목록'),
_ => id,
};
Widget btn(Widget icon, String tip, VoidCallback? f, {bool here = false}) => SizedBox(
width: 40,
height: 40,
child: IconButton(
tooltip: tip,
padding: EdgeInsets.zero,
isSelected: here,
icon: icon,
onPressed: f,
),
);
return Row(mainAxisSize: MainAxisSize.min, children: [
btn(
Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon),
width: 26, height: 26, filterQuality: FilterQuality.medium),
trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]),
scope == null ? null : () => toHome(context),
),
// 설치한 화면들 (환경 설정 > 컴포넌트 의 순서). 하나뿐이면 버튼 줄은 그것만.
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,
),
btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)),
]);
}
}
/// 화면 가운데를 좌우로 밀면 다음 · 이전 화면으로 (환경 설정 > 컴포넌트 의 순서, 끝 다음은 처음).
/// 안쪽의 옆으로 밀리는 것 (가로 목록 · 막대) 이 먼저 받고, 위 · 아래 가장자리 (1/5) 에서 시작한 것은 무시한다.
class SwipeNav extends StatefulWidget {
final String current;
final Widget child;
const SwipeNav({super.key, required this.current, required this.child});
@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 = 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.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: const 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.uiScale = AppSettings.clampUiScale(v));
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final s = c.settings.uiScale;
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 <= AppSettings.uiScaleMin + 0.001 ? null : () => _set(s - 0.1)),
Tooltip(
message: trf('기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서', [(c.settings.uiScaleDefault * 100).round()]),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _set(c.settings.uiScaleDefault),
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 >= AppSettings.uiScaleMax - 0.001 ? null : () => _set(s + 0.1)),
]),
);
},
);
}
/// 화면 전체를 [AppSettings.uiScale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치).
/// 앱의 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.uiScale;
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),
),
),
);
});
},
);
}
/// 환경 설정 · 종료 버튼. 모든 화면의 위쪽 막대 맨 오른쪽에 같은 크기로 놓는다.
class AppActions extends StatelessWidget {
/// 직접 주지 않으면 [AppScope] 의 것을 쓴다
final AppController? c;
final VoidCallback? onExit;
/// 환경 설정 화면 자신: 설정 버튼을 "지금 여기" 로 표시하고 누를 수 없게
final bool onSettingsPage;
/// 창이 좁을 때 CPU · MEM · DISK 를 숨긴다 (버튼 자리는 그대로)
final bool showUsage;
const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false, this.showUsage = true});
@override
Widget build(BuildContext context) {
final scope = AppScope.maybeOf(context);
final controller = c ?? scope?.controller;
final exit = onExit ?? scope?.onExit;
if (controller == null && exit == null) return const SizedBox();
final usage = controller?.usage;
final compact = isCompact(context);
return Row(mainAxisSize: MainAxisSize.min, children: [
// CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준)
if (usage != null && showUsage && !compact) UsageView(usage: usage),
// 화면 크기: − 100% +
if (controller != null) ScaleButtons(c: controller),
const SizedBox(width: 4),
if (controller != null)
IconButton(
tooltip: tr('환경 설정'),
icon: Icon(onSettingsPage ? Icons.settings : Icons.settings_outlined,
color: onSettingsPage ? JjColors.accent : null),
onPressed: onSettingsPage
? null
: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: controller))),
),
if (exit != null)
IconButton(
tooltip: tr('종료'),
icon: const Icon(Icons.power_settings_new, color: JjColors.danger),
onPressed: exit,
),
]);
}
}