JJ_MKVMaker/lib/ui/reader/reader_bars.dart

270 lines
11 KiB
Dart

// 보기 화면 막대 모듈: 위 막대 (넘치면 ⋮) · 아래 막대 (쪽 번호 · 밀대 · 쪽 이름) · 갤러리 줄
//
// 약속
// - 입력: 버튼 목록 ([ReaderBarItem] - 앞의 것일수록 먼저 보임) · 제목 · 쪽 수 · 지금 쪽 · 넘기는 방향 · 작은 그림 (Future)
// - 출력: 막대 위젯. 누름은 받은 콜백 ([ReaderAct.onTap] · onSlide · onTap) 으로만 알린다
// - 오류: 던지지 않는다. 작은 그림을 못 읽으면 깨진 그림 아이콘
// 다른 보기 화면도 다시 쓸 수 있다.
import 'package:flutter/material.dart';
import '../../core/reader_sources.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 위 · 아래 막대 색 (그림 위 · 검은 여백 위 어디서나 같은 띠로 보이게 거의 불투명)
const readerBarColor = Color(0xF0222428);
/// 갤러리 줄의 한 칸 너비
const readerThumbExtent = 64.0;
/// 위 막대 버튼 하나 (⋮ 메뉴에서는 [icon] 과 [tip] 으로 한 줄)
class ReaderAct {
final Widget icon;
final String tip;
final VoidCallback? onTap;
final bool on;
const ReaderAct(this.icon, this.tip, this.onTap, {this.on = false});
}
/// 위 막대의 한 칸 ([width] 만큼 자리를 차지). [seconds] 가 있으면 ▲ · 초 · ▼ 묶음
class ReaderBarItem {
final double width;
final List<ReaderAct> acts;
final int? seconds;
const ReaderBarItem(this.width, this.acts, {this.seconds});
}
/// [room] 너비에 앞에서부터 들어가는 칸 수. 모두 들어가지 않으면 ⋮ (48) 자리를 빼고 센다
int fitBarItems(List<ReaderBarItem> items, double room) {
if (items.fold<double>(0, (a, x) => a + x.width) <= room) return items.length;
var used = 48.0; // ⋮
var shown = 0;
while (shown < items.length && used + items[shown].width <= room) {
used += items[shown].width;
shown++;
}
return shown;
}
/// 막대 위의 버튼 하나
Widget readerBarButton(ReaderAct a, {bool compact = false}) => IconButton(
tooltip: a.tip,
// 막대는 밝은 화면에서도 어두운 띠 - 강조는 어두운 화면의 청록 (밝은 화면의 진한 청록은 어두운 띠 위에서 안 보임)
color: a.on ? JjPalette.dark.accent : Colors.white,
disabledColor: Colors.white38,
icon: a.icon,
visualDensity: compact ? VisualDensity.compact : null,
constraints: compact ? const BoxConstraints(minWidth: 36, minHeight: 40) : null,
padding: compact ? EdgeInsets.zero : null,
onPressed: a.onTap,
);
/// 위 막대: [닫기] · 제목 · 버튼 (오른쪽 끝, 못 들어간 것은 ⋮ 메뉴 - 없애지 않는다)
class ReaderTopBar extends StatelessWidget {
const ReaderTopBar({super.key, required this.title, required this.items});
final String title;
/// 버튼들 (그릴 때마다 부른다)
final List<ReaderBarItem> Function() items;
@override
Widget build(BuildContext context) {
return Container(
// 113: 반투명 검정은 그림 위에서만 보이고 옆의 검은 여백에서는 안 보여 막대가 갈라져 보였다 → 거의 불투명한 어두운 회색 한 줄
color: readerBarColor,
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
child: SizedBox(
height: 52,
child: LayoutBuilder(builder: (context, box) {
final items = this.items();
// 닫기 · 제목 (조금이라도) 을 뺀 자리에 들어가는 만큼 앞에서부터, 나머지는 ⋮ 메뉴 (없애지 않는다)
final shown = fitBarItems(items, box.maxWidth - 48 - 72);
final rest = [for (final x in items.skip(shown)) ...x.acts];
return Row(children: [
readerBarButton(ReaderAct(const Icon(Icons.arrow_back), tr('닫기'), () => Navigator.maybePop(context))),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
// 버튼은 오른쪽 끝에 붙인다 (113)
for (final x in items.take(shown))
if (x.seconds != null)
Row(mainAxisSize: MainAxisSize.min, children: [
readerBarButton(x.acts[0], compact: true),
SizedBox(
width: 40,
child: Text(trf('{0}초', [x.seconds!]),
textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 13)),
),
readerBarButton(x.acts[1], compact: true),
])
else
readerBarButton(x.acts.single),
if (rest.isNotEmpty)
PopupMenuButton<int>(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: (k) => rest[k].onTap?.call(),
itemBuilder: (_) => [
for (final (k, a) in rest.indexed)
PopupMenuItem<int>(
value: k,
enabled: a.onTap != null,
child: Row(children: [
IconTheme.merge(data: IconThemeData(color: a.on ? JjColors.accent : null), child: a.icon),
const SizedBox(width: 12),
Flexible(child: Text(a.tip)),
]),
),
],
),
]);
}),
),
);
}
}
/// 아래 막대: 쪽 번호 ("2-1 / 3장") · 밀대 · 쪽 이름 (오른쪽 끝). [gallery] 가 있으면 그 위에
class ReaderBottomBar extends StatelessWidget {
const ReaderBottomBar({
super.key,
required this.pages,
required this.slots,
required this.index,
required this.label,
required this.pageName,
required this.rtl,
required this.onSlide,
this.gallery,
});
/// 원본 장 수 · 보이는 쪽 수 · 지금 쪽 번호
final int pages;
final int slots;
final int index;
/// 지금 쪽 이름 ("12" · "12-1") · 파일 이름
final String label;
final String pageName;
final bool rtl;
final ValueChanged<int> onSlide;
final Widget? gallery;
@override
Widget build(BuildContext context) {
final n = pages;
final count = slots;
return Container(
color: readerBarColor,
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
?gallery,
Row(children: [
// 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장")
Text(
count != n ? trf('{0} / {1}장', [label, n]) : '${n == 0 ? 0 : label} / $n',
style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
Expanded(
child: count < 2
? const SizedBox(height: 48)
: Directionality(
textDirection: rtl ? TextDirection.rtl : TextDirection.ltr,
child: Slider(
value: index.clamp(0, count - 1).toDouble(),
max: (count - 1).toDouble(),
divisions: count - 1,
onChanged: (v) => onSlide(v.round()),
// ⑦ 어두운 막대 위 - 밝은 화면에서도 어두운 화면의 청록 (null 이면 테마 그대로)
activeColor: JjColors.isLight ? JjPalette.dark.accent : null,
),
),
),
// 113: 쪽 이름은 오른쪽 끝 (넓은 화면에서 가운데로 떨어지지 않게)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3),
child: Text(pageName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
]),
]),
);
}
}
/// 갤러리 줄을 [page] 장이 가운데 오게 할 위치 ([viewport] 보이는 너비, [max] 끝 위치)
double galleryOffset(int page, double viewport, double max) =>
(page * readerThumbExtent - viewport / 2 + readerThumbExtent / 2).clamp(0.0, max);
/// 갤러리 줄 [g] 를 [page] 장이 가운데 오게 ([animate] 면 0.2초 동안). 줄이 화면에 없으면 아무것도 안 함
void scrollGalleryTo(ScrollController g, int page, {bool animate = true}) {
if (!g.hasClients) return;
final pos = g.position;
final to = galleryOffset(page, pos.viewportDimension, pos.maxScrollExtent);
animate ? g.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : g.jumpTo(to);
}
/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (칸마다 ValueKey('thumb$i'), 지금 장은 굵은 청록 테두리)
class ReaderGalleryStrip extends StatelessWidget {
const ReaderGalleryStrip({
super.key,
required this.controller,
required this.count,
required this.current,
required this.rtl,
required this.thumb,
required this.onTap,
});
final ScrollController controller;
final int count;
final int current;
final bool rtl;
final Future<ReaderImage> Function(int page) thumb;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) => SizedBox(
height: 76,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
reverse: rtl,
itemExtent: readerThumbExtent,
itemCount: count,
itemBuilder: (_, i) => GestureDetector(
key: ValueKey('thumb$i'),
onTap: () => onTap(i),
child: Container(
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
border: Border.all(color: i == current ? JjPalette.dark.accent : Colors.white24, width: i == current ? 2 : 1)),
child: FutureBuilder<ReaderImage>(
future: thumb(i),
builder: (_, snap) {
final r = snap.data;
if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20);
if (r == null) return const SizedBox();
return r.image != null
? RawImage(image: r.image, fit: BoxFit.cover)
: Image(
image: r.provider!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20));
},
),
),
),
),
);
}