270 lines
11 KiB
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));
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|