// 보기 화면 막대 모듈: 위 막대 (넘치면 ⋮) · 아래 막대 (쪽 번호 · 밀대 · 쪽 이름) · 갤러리 줄 // // 약속 // - 입력: 버튼 목록 ([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 acts; final int? seconds; const ReaderBarItem(this.width, this.acts, {this.seconds}); } /// [room] 너비에 앞에서부터 들어가는 칸 수. 모두 들어가지 않으면 ⋮ (48) 자리를 빼고 센다 int fitBarItems(List items, double room) { if (items.fold(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 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( 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( 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 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 Function(int page) thumb; final ValueChanged 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( 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)); }, ), ), ), ), ); }