300 lines
12 KiB
Dart
300 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../app/app_controller.dart';
|
||
import '../app/bookmarks_controller.dart';
|
||
import '../app/download_manager.dart';
|
||
import '../app/settings.dart';
|
||
import '../services/system_usage.dart';
|
||
import 'browser_page.dart';
|
||
import 'downloads_page.dart';
|
||
import 'settings_page.dart';
|
||
import 'theme.dart';
|
||
|
||
/// 모든 화면의 위쪽 막대 높이 · 오른쪽 여백 (환경 설정 · 종료 버튼이 어느 화면에서나 같은 자리에 오도록)
|
||
const double appBarHeight = 56;
|
||
const double appBarRightPadding = 12;
|
||
|
||
/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다
|
||
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;
|
||
const AppNavButtons({super.key, this.onDownloadsPage = false});
|
||
|
||
static const double width = 4 * 40;
|
||
|
||
/// MKV 화면 (맨 처음 화면) 까지 돌아가기
|
||
static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst);
|
||
|
||
/// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 웹 브라우저면 브라우저를 연다
|
||
static void toHome(BuildContext context) {
|
||
final scope = AppScope.maybeOf(context);
|
||
final nav = Navigator.of(context);
|
||
nav.popUntil((r) => r.isFirst);
|
||
if (scope != null && scope.controller.settings.startScreen == 'browser' && scope.bookmarks != null) {
|
||
nav.push(MaterialPageRoute<void>(
|
||
builder: (_) => BrowserPage(c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!)));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scope = AppScope.maybeOf(context);
|
||
final nav = Navigator.maybeOf(context);
|
||
final atRoot = !(nav?.canPop() ?? false);
|
||
final homeIsBrowser = scope?.controller.settings.startScreen == 'browser' && scope?.bookmarks != null;
|
||
final downloads = scope?.downloads;
|
||
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 SizedBox(
|
||
width: width,
|
||
child: Row(children: [
|
||
btn(
|
||
Image.asset('assets/icon/app_icon_256.png', width: 26, height: 26, filterQuality: FilterQuality.medium),
|
||
'홈 화면 (${homeIsBrowser ? '웹 브라우저' : 'MKV 화면'}) · 환경 설정에서 바꿈',
|
||
scope == null ? null : () => toHome(context),
|
||
),
|
||
btn(
|
||
Icon(Icons.video_library_outlined, color: atRoot ? JjColors.accent : null),
|
||
atRoot ? 'MKV 화면 (지금 여기)' : 'MKV 화면으로',
|
||
atRoot ? null : () => toMkv(context),
|
||
here: atRoot,
|
||
),
|
||
btn(const Icon(Icons.arrow_back), '뒤로', atRoot ? null : () => Navigator.maybePop(context)),
|
||
btn(
|
||
Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null),
|
||
onDownloadsPage ? '다운로드 목록 (지금 여기)' : '다운로드 목록',
|
||
downloads == null || onDownloadsPage
|
||
? null
|
||
: () => Navigator.push(context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: downloads))),
|
||
here: onDownloadsPage,
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "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
|
||
? 'PC 전체 CPU · 메모리 사용량'
|
||
: 'PC 전체 사용량\nCPU ${(s.cpu * 100).round()}%\n'
|
||
'메모리 ${_gb(s.memUsed)} / ${_gb(s.memTotal)}GB (${(s.mem * 100).round()}%)'
|
||
'${free == null ? '' : '\n다운로드 디스크 ${s.disk} 남은 용량 ${_size(free)}'
|
||
'${s.diskTotal == null ? '' : ' / 전체 ${_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, '화면 작게', s <= AppSettings.uiScaleMin + 0.001 ? null : () => _set(s - 0.1)),
|
||
Tooltip(
|
||
message: '기본 크기 (${(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, '화면 크게', 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;
|
||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||
// CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준)
|
||
if (usage != null && showUsage) UsageView(usage: usage),
|
||
// 화면 크기: − 100% +
|
||
if (controller != null) ScaleButtons(c: controller),
|
||
const SizedBox(width: 4),
|
||
if (controller != null)
|
||
IconButton(
|
||
tooltip: '환경 설정',
|
||
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: '종료',
|
||
icon: const Icon(Icons.power_settings_new, color: JjColors.danger),
|
||
onPressed: exit,
|
||
),
|
||
]);
|
||
}
|
||
}
|