From 4902d25a0ae0c888c9ebc672b1e059db50e8d551 Mon Sep 17 00:00:00 2001 From: seizeh Date: Sun, 26 Jul 2026 11:35:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=84=A4=20=ED=9B=84=EA=B8=B0?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=202=EC=A2=85=EC=9D=84=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=E2=80=94=20=EC=87=BC?= =?UTF-8?q?=EC=B8=A0=ED=98=95=20=EC=83=81=EC=84=B8=C2=B7=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20=EB=AC=B8=EB=B2=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 후기 상세를 게시글과 동일한 쇼츠형 풀스크린으로 재작성: 미디어(사진≤5+ 영상≤2)를 PageView 좌우 스와이프 + 상단 1/N 인디케이터로. 영상 페이지는 현재 페이지일 때만 자동재생·루프, 탭 재생/일시정지, 최하단 진행바(게시글 문법). 미디어 없는 후기는 카드 블롭 배경 + 본문 센터(탭 확장) 풀스크린. - 하단 오버레이(카드 미러 타이포): 업체 혜택 배지(표시광고법 — 흰 필름 알약으로 항상 노출)·방문 차수·내 후기·날짜, 별점(제목 자리), 본문 탭 확장(블러 패널 동반 확장), 닉네임·댓글. 블러는 현재 페이지 사본 σ8+마스크 (MediaOverlayPanel 재사용). 축소 전환은 기존 CollapseRoute 유지 — 미디어가 타일(1:1)로 줄고 패널·인디케이터·진행바는 페이드아웃. - 댓글 바텀시트 공용화: showCommentsSheet + CommentsSheetShell 추출(키보드 배리어 off·포커스 중 드래그 잠금·리스트 탭 키보드 닫기) — 게시글 상세를 셸로 전환하고 후기 상세(열람 게스트 허용·입력바는 로그인 시)도 동일 적용. 후기의 인라인 댓글·하단 입력바 제거. - 후기 작성은 기능·서버 계약 그대로(별점·본문·사진 자유비율 다중·영상 uploadVideo·대가성 체크 필수 유지) 시각 문법만 게시글 작성과 통일: 앱바 등록 액션, 섹션 라벨·간격, 첨부 바텀시트(사진/동영상), 선택 카드형 대가성 체크, 별점 Pressable. - 공용화: MediaOverlayPanel·BlobHeroContent(post_media_hero), 본문 탭 확장 ExpandableOverlayText(post_card) 공개로 후기 상세가 재사용. Co-Authored-By: Claude Fable 5 --- lib/screen/facility_review_screen.dart | 533 +++++++------- lib/screen/post_detail_screen.dart | 217 +----- lib/screen/review_detail_screen.dart | 983 ++++++++++++++++--------- lib/widgets/comments_sheet.dart | 227 ++++++ lib/widgets/post_card.dart | 12 +- lib/widgets/post_media_hero.dart | 19 +- 6 files changed, 1193 insertions(+), 798 deletions(-) create mode 100644 lib/widgets/comments_sheet.dart diff --git a/lib/screen/facility_review_screen.dart b/lib/screen/facility_review_screen.dart index 571467e..fe788d5 100644 --- a/lib/screen/facility_review_screen.dart +++ b/lib/screen/facility_review_screen.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import '../models/facility_review.dart'; +import '../motion/motion.dart'; import '../services/facility_repository.dart'; import '../services/facility_review_repository.dart'; import '../services/storage_service.dart'; import '../theme/app_palette.dart'; import '../widgets/media_widgets.dart'; -/// 시설 후기 작성/수정 (0022). 갤러리 다중 사진 허용. 카페는 작성 시 승격. +/// 시설 후기 작성/수정 (0022) — 게시글 작성(post_create_screen)과 같은 디자인 +/// 문법: 섹션 라벨·간격·타이포, 앱바 '등록' 액션, 첨부는 바텀시트(사진/동영상). +/// 갤러리 다중 사진 허용(자유 비율 — 크롭 없음). 카페는 작성 시 승격. /// 저장 성공 시 true 를 pop. class FacilityReviewScreen extends StatefulWidget { final Facility facility; @@ -53,6 +56,59 @@ class _FacilityReviewScreenState extends State { ); } + /// 첨부 바텀시트 — 사진/동영상 선택(게시글 작성의 첨부 시트와 동일 문법). + Future _chooseAttachment() async { + final canPhoto = _photos.length < _maxPhotos && !_uploading; + final canVideo = _videos.length < _maxVideos && !_uploadingVideo; + final src = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '첨부하기', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + ), + ListTile( + enabled: canPhoto, + leading: Icon( + Icons.photo_library_outlined, + color: canPhoto + ? context.colors.primaryDark + : context.colors.textTertiary, + ), + title: const Text('사진'), + subtitle: Text('자유 비율 · 최대 $_maxPhotos장'), + onTap: () => Navigator.pop(ctx, 'photo'), + ), + ListTile( + enabled: canVideo, + leading: Icon( + Icons.videocam_outlined, + color: canVideo + ? context.colors.primaryDark + : context.colors.textTertiary, + ), + title: const Text('동영상'), + subtitle: Text('최대 60초 · 100MB · $_maxVideos개'), + onTap: () => Navigator.pop(ctx, 'video'), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (src == 'photo') return _addPhotos(); + if (src == 'video') return _addVideo(); + } + Future _addPhotos() async { if (_photos.length >= _maxPhotos || _uploading) return; final files = await StorageService.instance.pickImages(); @@ -177,304 +233,273 @@ class _FacilityReviewScreenState extends State { final editing = widget.existing != null; return Scaffold( backgroundColor: context.colors.background, + // 등록은 앱바 액션 — 게시글 작성과 동일 문법. appBar: AppBar( title: Text(editing ? '후기 수정' : '후기 작성'), actions: [ if (editing) IconButton( icon: Icon(Icons.delete_outline, color: context.colors.danger), + tooltip: '후기 삭제', onPressed: _submitting ? null : _delete, ), + TextButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + editing ? '수정' : '등록', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), ], ), body: SafeArea( - child: ListView( + child: SingleChildScrollView( padding: const EdgeInsets.all(20), - children: [ - Text( - widget.facility.name, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: context.colors.textPrimary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 시설 이름 + 안내 캡션(게시글 작성의 라벨·캡션 문법). + Text( + widget.facility.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: context.colors.textPrimary, + ), ), - ), - const SizedBox(height: 16), - Text( - '별점', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: context.colors.textSecondary, + const SizedBox(height: 4), + Text( + '방문 경험을 별점과 후기로 남겨주세요', + style: TextStyle( + fontSize: 12, + color: context.colors.textSecondary, + ), ), - ), - const SizedBox(height: 8), - Row( - children: [ - for (var i = 1; i <= 5; i++) - GestureDetector( - onTap: () => setState(() => _rating = i), - child: Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - i <= _rating ? Icons.star : Icons.star_border, - size: 34, - color: i <= _rating - ? const Color(0xFFFFB300) - : context.colors.border, + const SizedBox(height: 24), + const _SectionLabel('별점'), + Row( + children: [ + for (var i = 1; i <= 5; i++) + Pressable( + scaleTo: 0.85, + borderRadius: BorderRadius.circular(100), + onTap: () => setState(() => _rating = i), + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + i <= _rating + ? Icons.star_rounded + : Icons.star_outline_rounded, + size: 36, + color: i <= _rating + ? const Color(0xFFFFB300) + : context.colors.border, + ), ), ), - ), - ], - ), - const SizedBox(height: 20), - Text( - '후기', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: context.colors.textSecondary, + ], ), - ), - const SizedBox(height: 8), - TextField( - controller: _contentCtrl, - minLines: 3, - maxLines: 8, - maxLength: 1000, - decoration: InputDecoration( - hintText: '시설에 대한 후기를 남겨주세요', - filled: true, - fillColor: context.colors.surfaceMuted, - contentPadding: const EdgeInsets.all(14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: context.colors.border), - ), + const SizedBox(height: 24), + const _SectionLabel('후기'), + TextField( + controller: _contentCtrl, + minLines: 5, + maxLines: 10, + maxLength: 1000, + decoration: const InputDecoration(hintText: '시설에 대한 후기를 남겨주세요'), ), - ), - const SizedBox(height: 12), - Text( - '사진 (${_photos.length}/$_maxPhotos)', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: context.colors.textSecondary, + const SizedBox(height: 12), + _SectionLabel( + '사진·동영상 · 사진 ${_photos.length}/$_maxPhotos · ' + '동영상 ${_videos.length}/$_maxVideos', ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var i = 0; i < _photos.length; i++) - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _photos.length; i++) + _thumb( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), child: Image.network( _photos[i], - width: 72, - height: 72, + width: 76, + height: 76, fit: BoxFit.cover, ), ), - Positioned( - right: 0, - top: 0, - child: GestureDetector( - onTap: () => setState(() => _photos.removeAt(i)), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - size: 16, - color: Colors.white, - ), - ), - ), - ), - ], - ), - if (_photos.length < _maxPhotos) - GestureDetector( - onTap: _addPhotos, - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: context.colors.surfaceMuted, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: context.colors.border), - ), - child: _uploading - ? const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : Icon( - Icons.add_a_photo_outlined, - color: context.colors.textTertiary, - ), + onRemove: () => setState(() => _photos.removeAt(i)), ), - ), - ], - ), - const SizedBox(height: 12), - Text( - '동영상 (${_videos.length}/$_maxVideos)', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: context.colors.textSecondary, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var i = 0; i < _videos.length; i++) - Stack( - children: [ - SizedBox( - width: 72, - height: 72, + for (var i = 0; i < _videos.length; i++) + _thumb( + child: SizedBox( + width: 76, + height: 76, child: VideoPosterTile( videoUrl: _videos[i].url, posterUrl: _videos[i].thumbUrl, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), badgeSize: 28, cacheWidth: 200, ), ), - Positioned( - right: 0, - top: 0, - child: GestureDetector( - onTap: () => setState(() => _videos.removeAt(i)), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - size: 16, - color: Colors.white, - ), - ), + onRemove: () => setState(() => _videos.removeAt(i)), + ), + // 첨부 추가 — 탭하면 사진/동영상 선택 바텀시트. + if (_photos.length < _maxPhotos || + _videos.length < _maxVideos) + Pressable( + borderRadius: BorderRadius.circular(12), + onTap: (_uploading || _uploadingVideo) + ? null + : _chooseAttachment, + child: Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: context.colors.surfaceMuted, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: context.colors.border), ), - ), - ], - ), - if (_videos.length < _maxVideos) - GestureDetector( - onTap: _addVideo, - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: context.colors.surfaceMuted, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: context.colors.border), - ), - child: _uploadingVideo - ? const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + child: (_uploading || _uploadingVideo) + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), + ) + : Icon( + Icons.add_rounded, + size: 26, + color: context.colors.textTertiary, ), - ) - : Icon( - Icons.videocam_outlined, - color: context.colors.textTertiary, - ), + ), + ), + ], + ), + const SizedBox(height: 24), + // 표시광고법: 대가성 후기는 경제적 이해관계 표시 의무 — 체크 시 + // 후기 카드·상세·공유 뷰어에 '업체 혜택 받고 작성' 배지가 붙는다. + // 게시글 작성의 선택 카드 문법(선택 시 색 채움 + 테두리 강조). + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => setState(() => _hasIncentive = !_hasIncentive), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _hasIncentive + ? context.colors.primarySoft.withValues(alpha: 0.3) + : context.colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _hasIncentive + ? context.colors.primary + : context.colors.border, + width: _hasIncentive ? 1.5 : 0.5, ), ), - ], - ), - const SizedBox(height: 16), - // 표시광고법: 대가성 후기는 경제적 이해관계 표시 의무 — 체크 시 - // 후기 카드·상세·공유 뷰어에 '업체 혜택 받고 작성' 배지가 붙는다. - InkWell( - onTap: () => setState(() => _hasIncentive = !_hasIncentive), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - SizedBox( - width: 24, - height: 24, - child: Checkbox( - value: _hasIncentive, - onChanged: (v) => - setState(() => _hasIncentive = v ?? false), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _hasIncentive + ? Icons.check_circle + : Icons.radio_button_off, + color: _hasIncentive + ? context.colors.primary + : context.colors.textTertiary, ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '업체로부터 할인·사은품 등 혜택을 받고 작성해요', - style: TextStyle( - fontSize: 13, - color: context.colors.textSecondary, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '업체로부터 할인·사은품 등 혜택을 받고 작성해요', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.colors.textPrimary, + height: 1.4, + ), + ), + if (_hasIncentive) ...[ + const SizedBox(height: 4), + Text( + '후기에 \'업체 혜택 받고 작성\' 표시가 함께 노출돼요.', + style: TextStyle( + fontSize: 12, + color: context.colors.textTertiary, + height: 1.4, + ), + ), + ], + ], ), ), - ), - ], - ), - ), - ), - if (_hasIncentive) - Padding( - padding: const EdgeInsets.only(left: 34, top: 2), - child: Text( - '후기에 \'업체 혜택 받고 작성\' 표시가 함께 노출돼요.', - style: TextStyle( - fontSize: 12, - color: context.colors.textTertiary, + ], ), ), ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _submitting ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: context.colors.primaryDark, - foregroundColor: context.colors.textOnPrimary, - padding: const EdgeInsets.symmetric(vertical: 14), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } + + /// 첨부 썸네일 + 우상단 제거 버튼(공용). + Widget _thumb({required Widget child, required VoidCallback onRemove}) { + return Stack( + children: [ + child, + Positioned( + right: 4, + top: 4, + child: GestureDetector( + onTap: onRemove, + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, ), - child: _submitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - editing ? '수정하기' : '등록하기', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), + child: const Icon(Icons.close, size: 16, color: Colors.white), ), - ], + ), + ), + ], + ); + } +} + +/// 섹션 라벨 — 게시글 작성 화면과 동일한 타이포. +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + text, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: context.colors.textPrimary, ), ), ); diff --git a/lib/screen/post_detail_screen.dart b/lib/screen/post_detail_screen.dart index 6318cc2..84277c8 100644 --- a/lib/screen/post_detail_screen.dart +++ b/lib/screen/post_detail_screen.dart @@ -8,13 +8,13 @@ import '../motion/motion.dart'; import '../services/business_repository.dart'; import '../services/chat_launcher.dart'; import '../services/community_repository.dart'; -import '../services/keyboard_barrier.dart'; import '../services/report_repository.dart'; import '../services/session.dart'; import '../state/post_detail_state.dart'; import '../theme/app_palette.dart'; import '../utils/labels.dart' show timeAgo; import '../utils/share_links.dart'; +import '../widgets/comments_sheet.dart'; import '../widgets/overlay_icon_button.dart'; import '../widgets/post_media_hero.dart'; import '../widgets/report_sheet.dart'; @@ -148,27 +148,23 @@ class _PostDetailScreenState extends State { /// 댓글 바텀시트(전 카테고리) — 기존 댓글 리스트 + 입력창을 시트로. /// 열람은 게스트도 가능(작성은 [_sendComment] 의 가드가 막는다). void _openComments() { - // 전역 키보드 배리어를 시트가 열려 있는 동안 끈다 — 배리어가 켜져 있으면 - // 키보드가 뜬 상태의 첫 탭(전송 버튼 포함)을 흡수해 "한 번 닫혀야 눌리는" - // 문제가 생긴다. 리스트 탭으로 키보드 닫기는 시트 내부에서 처리. - keyboardBarrierEnabled.value = false; - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: context.colors.background, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (_) => _CommentsSheet( - state: _state, - controller: _commentCtrl, + // 공용 셸 — 배리어 off·포커스 중 드래그 잠금은 showCommentsSheet/셸이 처리. + showCommentsSheet( + context, + builder: (_) => CommentsSheetShell( + listenable: _state, + title: () => '댓글 ${_state.comments.length}', + inputController: _commentCtrl, + sending: () => _state.sending, onSend: _sendComment, - onReport: _openCommentMenu, + listBuilder: (_) => _CommentList( + loading: _state.loadingComments, + comments: _state.comments, + myUserId: SessionManager.instance.user?.id, + onReport: _openCommentMenu, + ), ), - ).whenComplete(() { - keyboardBarrierEnabled.value = true; - FocusManager.instance.primaryFocus?.unfocus(); - }); + ); } Future _apply() async { @@ -628,187 +624,6 @@ class _CommentList extends StatelessWidget { } } -/// 댓글 바텀시트(전 카테고리) — 그랩바·카운트 헤더 + 댓글 리스트(드래그 확장) + -/// 하단 입력창. 상태는 [PostDetailState] 를 그대로 구독해 작성·갱신이 화면과 -/// 실시간으로 동기화된다(시트를 닫으면 오버레이 카운트에도 반영). -class _CommentsSheet extends StatefulWidget { - final PostDetailState state; - final TextEditingController controller; - final VoidCallback onSend; - final void Function(Comment) onReport; - - const _CommentsSheet({ - required this.state, - required this.controller, - required this.onSend, - required this.onReport, - }); - - @override - State<_CommentsSheet> createState() => _CommentsSheetState(); -} - -class _CommentsSheetState extends State<_CommentsSheet> { - PostDetailState get state => widget.state; - - /// 입력 포커스 추적 — 작성 중에는 시트 드래그(확장/축소)를 잠근다. - final _focus = FocusNode(); - - @override - void initState() { - super.initState(); - _focus.addListener(_onFocus); - } - - void _onFocus() { - if (mounted) setState(() {}); - } - - @override - void dispose() { - _focus.removeListener(_onFocus); - _focus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final composing = _focus.hasFocus; - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.92, - builder: (context, scroll) => ListenableBuilder( - listenable: state, - builder: (context, _) => Column( - children: [ - const SizedBox(height: 10), - // 그랩바. - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: context.colors.border, - borderRadius: BorderRadius.circular(100), - ), - ), - const SizedBox(height: 14), - Text( - '댓글 ${state.comments.length}', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), - ), - const SizedBox(height: 6), - Expanded( - // 리스트 빈 곳 탭 → 키보드 닫기(전역 배리어는 시트 동안 꺼짐). - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - child: ListView( - controller: scroll, - // 작성 중엔 시트 확장/축소 드래그 잠금(스크롤 물리 차단 — - // DraggableScrollableSheet 는 이 스크롤러블로만 움직인다). - physics: composing - ? const NeverScrollableScrollPhysics() - : null, - padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), - children: [ - _CommentList( - loading: state.loadingComments, - comments: state.comments, - myUserId: SessionManager.instance.user?.id, - onReport: widget.onReport, - ), - ], - ), - ), - ), - // 입력바 — 키보드가 올라오면 그 위로 붙는다. - Container( - decoration: BoxDecoration( - color: context.colors.surface, - border: Border( - top: BorderSide(color: context.colors.border, width: 0.5), - ), - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - 12, - 8, - 12, - 8 + MediaQuery.viewInsetsOf(context).bottom, - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: widget.controller, - focusNode: _focus, - minLines: 1, - maxLines: 4, - decoration: InputDecoration( - hintText: '댓글을 입력하세요', - filled: true, - fillColor: context.colors.surfaceMuted, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide( - color: context.colors.primary, - width: 1.2, - ), - ), - ), - ), - ), - const SizedBox(width: 6), - Container( - decoration: BoxDecoration( - color: context.colors.primaryDark, - shape: BoxShape.circle, - ), - child: IconButton( - icon: state.sending - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.textOnPrimary, - ), - ) - : Icon( - Icons.arrow_upward, - color: context.colors.textOnPrimary, - ), - onPressed: state.sending ? null : widget.onSend, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} - /// 오버레이 위 지원 액션 버튼 — 어두운 스크림/미디어 위에서도 읽히는 밝은 /// 필름 알약(카테고리 태그와 같은 문법). 상세 전용(카드에는 없음). class _OverlayActionButton extends StatelessWidget { diff --git a/lib/screen/review_detail_screen.dart b/lib/screen/review_detail_screen.dart index 15bf62b..89cecc8 100644 --- a/lib/screen/review_detail_screen.dart +++ b/lib/screen/review_detail_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:video_player/video_player.dart'; -import '../models/community.dart' show kPostImageAspectRatio; import '../models/facility_review.dart'; import '../motion/motion.dart'; import '../services/business_repository.dart'; @@ -10,16 +10,26 @@ import '../services/session.dart'; import '../theme/app_palette.dart'; import '../utils/labels.dart' show timeAgo; import '../widgets/blob_background.dart'; +import '../widgets/comments_sheet.dart'; import '../widgets/media_widgets.dart'; import '../widgets/overlay_icon_button.dart'; +import '../widgets/post_card.dart' show ExpandableOverlayText; +import '../widgets/post_media_hero.dart' + show BlobHeroContent, MediaOverlayPanel; import '../widgets/review_cards.dart'; import 'user_profile_screen.dart'; -/// 시설 방문 후기 상세 — 게시글 상세와 동일한 문법. +/// 시설 방문 후기 상세 — 게시글 상세와 동일한 쇼츠형 풀스크린 문법. /// -/// 후기 카드 자리에서 펼쳐지고(originRect), 최상단에서 아래로 당기면 카드로 -/// 축소되며 닫힌다(CollapsibleView). 히어로는 사진 후기면 대표 사진, -/// 사진 없는 후기면 카드와 같은 블롭 배경 + 본문(축소 시 카드와 겹쳐 안착). +/// 미디어(사진 최대 5 + 영상 최대 2)가 화면 전체를 채우고 좌우 스와이프로 +/// 넘긴다(PageView + 상단 1/N 인디케이터). 영상 페이지는 현재 페이지일 때만 +/// 자동재생·루프하고, 탭 = 재생/일시정지, 최하단 얇은 진행바(게시글 문법). +/// 미디어 없는 후기는 후기 카드의 블롭 배경 + 본문 센터 배치를 풀스크린으로. +/// +/// 하단 오버레이(카드 미러): 업체 혜택 배지(표시광고법 — 반드시 노출)·방문 +/// 차수·날짜, 별점, 본문(탭 확장 — 블러 패널이 함께 위로 자람), 닉네임·댓글. +/// 블러는 게시글과 같은 σ8 사본+마스크. 댓글은 공용 바텀시트. +/// 아래로 당기면 카드로 축소(CollapsibleView — 기존 라우트 문법 유지). class ReviewDetailScreen extends StatefulWidget { final ReviewCardData review; @@ -45,21 +55,70 @@ class ReviewDetailScreen extends StatefulWidget { State createState() => _ReviewDetailScreenState(); } +/// 미디어 페이지 — 사진 URL 또는 영상. +class _MediaPage { + final String? photoUrl; + final ReviewVideo? video; + const _MediaPage.photo(this.photoUrl) : video = null; + const _MediaPage.video(this.video) : photoUrl = null; +} + class _ReviewDetailScreenState extends State { final _scroll = ScrollController(); - // 댓글 — reviewId 가 있는 후기(시설 후기)에만 붙는다. + // ── 미디어 페이징 ── + late final List<_MediaPage> _pages = [ + for (final url in widget.review.photoUrls) _MediaPage.photo(url), + for (final v in widget.review.videos) _MediaPage.video(v), + ]; + final _pageCtrl = PageController(); + int _page = 0; + + /// 영상 페이지의 컨트롤러(페이지 인덱스 → 컨트롤러) — 화면이 소유. + final Map _videoCtrls = {}; + final Set _videoErrors = {}; + + // ── 축소 전환(카드 미러) — 게시글 히어로와 같은 문법 ── + Animation? _progress; + bool _mirror = false; + bool _mirrorInit = false; + + /// 본문 펼침(오버레이 미리보기 또는 블롭 센터 본문). + bool _expanded = false; + + // ── 댓글 — reviewId 가 있는 후기(시설 후기)에만 붙는다 ── final _commentCtrl = TextEditingController(); List _comments = []; bool _loadingComments = false; bool _sending = false; + /// 댓글 시트 갱신 노티파이어 — 시트(별도 라우트)가 이 값으로 다시 그린다. + final _commentsRev = ValueNotifier(0); + String? get _reviewId => widget.review.reviewId; bool get _loggedIn => SessionManager.instance.user != null; @override void initState() { super.initState(); + // 영상 페이지 컨트롤러 — 미리 만들고, 재생은 현재 페이지일 때만(루프). + for (var i = 0; i < _pages.length; i++) { + final v = _pages[i].video; + if (v == null) continue; + final ctrl = VideoPlayerController.networkUrl(Uri.parse(v.url)); + _videoCtrls[i] = ctrl; + ctrl.setLooping(true); + ctrl + .initialize() + .then((_) { + if (!mounted) return; + setState(() {}); + if (_page == i) ctrl.play(); + }) + .catchError((Object e) { + if (mounted) setState(() => _videoErrors.add(i)); + }); + } if (_reviewId != null) _loadComments(); if (widget.fromDeepLink && _reviewId != null) { WidgetsBinding.instance.addPostFrameCallback( @@ -68,6 +127,52 @@ class _ReviewDetailScreenState extends State { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final p = CollapseProgress.of(context); + if (!identical(p, _progress)) { + _progress?.removeListener(_onCollapseTick); + _progress = p; + _progress?.addListener(_onCollapseTick); + } + if (!_mirrorInit) { + _mirrorInit = true; + // 전환 없이 열린 화면(비확장 진입)은 처음부터 풀스크린 배치. + _mirror = (p?.value ?? 1) < 1.0; + } + } + + void _onCollapseTick() { + final want = (_progress?.value ?? 1) < 1.0; + if (want != _mirror && mounted) setState(() => _mirror = want); + } + + @override + void dispose() { + _progress?.removeListener(_onCollapseTick); + for (final c in _videoCtrls.values) { + c.dispose(); + } + _pageCtrl.dispose(); + _scroll.dispose(); + _commentCtrl.dispose(); + _commentsRev.dispose(); + super.dispose(); + } + + /// 페이지 전환 — 현재 페이지의 영상만 재생, 나머지는 일시정지. + void _onPageChanged(int i) { + setState(() => _page = i); + _videoCtrls.forEach((idx, c) { + if (idx == i) { + if (c.value.isInitialized) c.play(); + } else { + c.pause(); + } + }); + } + /// 딥링크 진입 후 내가 이 시설의 업주(개인 모드)면 업체 모드 전환을 제안한다. Future _maybeSuggestSwitch() async { final ok = await BusinessRepository.instance.shouldSuggestBusinessSwitch( @@ -102,15 +207,14 @@ class _ReviewDetailScreenState extends State { ); } - @override - void dispose() { - _scroll.dispose(); - _commentCtrl.dispose(); - super.dispose(); - } + // ── 댓글 ── + + /// 시트(별도 라우트)에도 최신 상태를 반영. + void _syncSheet() => _commentsRev.value++; Future _loadComments() async { setState(() => _loadingComments = true); + _syncSheet(); try { final list = await FacilityReviewRepository.instance.fetchComments( _reviewId!, @@ -124,12 +228,14 @@ class _ReviewDetailScreenState extends State { if (!mounted) return; setState(() => _loadingComments = false); } + _syncSheet(); } Future _sendComment() async { final text = _commentCtrl.text.trim(); if (text.isEmpty || _sending) return; setState(() => _sending = true); + _syncSheet(); try { await FacilityReviewRepository.instance.addComment(_reviewId!, text); _commentCtrl.clear(); @@ -142,6 +248,7 @@ class _ReviewDetailScreenState extends State { ).showSnackBar(const SnackBar(content: Text('댓글 작성에 실패했어요'))); } finally { if (mounted) setState(() => _sending = false); + _syncSheet(); } } @@ -175,6 +282,29 @@ class _ReviewDetailScreenState extends State { } } + /// 댓글 바텀시트 — 게시글과 동일한 공용 셸(배리어 off·포커스 중 드래그 잠금). + /// 열람은 게스트도 가능, 입력바는 로그인 시에만. + void _openComments() { + if (_reviewId == null) return; + showCommentsSheet( + context, + builder: (_) => CommentsSheetShell( + listenable: _commentsRev, + title: () => '댓글 ${_comments.length}', + inputController: _commentCtrl, + sending: () => _sending, + onSend: _sendComment, + showInput: _loggedIn, + listBuilder: (_) => _ReviewCommentList( + loading: _loadingComments, + comments: _comments, + onDelete: _confirmDeleteComment, + onOpenProfile: _openAuthorProfile, + ), + ), + ); + } + /// 내 후기 삭제 — 확인 후 제공자 콜백(삭제 API + 목록 갱신) 실행, 성공 시 닫기. Future _confirmDelete() async { final ok = await showDialog( @@ -227,19 +357,13 @@ class _ReviewDetailScreenState extends State { ); } - /// 히어로(블롭)에 본문이 다 담겼는지 — 게시글 상세와 같은 기준(9줄·짧은 글). - bool get _heroHoldsFullContent { - final c = widget.review.content ?? ''; - return c.length <= 180 && '\n'.allMatches(c).length < 9; - } + /// 카드 복귀는 전환이 끝나기 전에 빠르게, 풀스크린 안착은 여유 있게. + Duration get _anchorDuration => Duration(milliseconds: _mirror ? 150 : 380); @override Widget build(BuildContext context) { - final r = widget.review; - final hasPhoto = r.photoUrls.isNotEmpty; - - // 게시글 상세와 동일 — 테마 밝기 기준 상태바 아이콘 유지, 어두운 사진 위 - // 가독성은 히어로 상단의 밝은 스크림이 담당. + // 게시글 상세와 동일 — 테마 밝기 기준 상태바 아이콘 유지, 어두운 미디어 위 + // 가독성은 상단의 밝은 스크림이 담당. final overlay = Theme.of(context).brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark; @@ -251,12 +375,9 @@ class _ReviewDetailScreenState extends State { cardRadius: 14, // _ReviewCard 와 동일 곡률 scrollController: _scroll, builder: (context, physics) => Scaffold( - backgroundColor: context.colors.background, + // 투명 — 축소 전환 중 카드 아래로 뒤 화면이 비친다(게시글과 동일). + backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, - // 댓글 입력 바 — 시설 후기(reviewId 보유) + 로그인 상태에서만. - bottomNavigationBar: (_reviewId != null && _loggedIn) - ? _commentInputBar() - : null, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, @@ -265,7 +386,7 @@ class _ReviewDetailScreenState extends State { automaticallyImplyLeading: false, actions: [ // 내 후기만 삭제 가능(게시글 상세의 앱바 액션 문법). - if (r.isMine && r.onDelete != null) + if (widget.review.isMine && widget.review.onDelete != null) OverlayIconButton( icon: Icons.delete_outline, tooltip: '내 후기 삭제', @@ -275,21 +396,16 @@ class _ReviewDetailScreenState extends State { const SizedBox(width: 8), ], ), + // 쇼츠형 — 화면 = 전체화면 미디어 하나. 뷰포트 높이 1장짜리 리스트로 + // CollapsibleView 의 당겨서 축소하는 드래그 메커니즘만 보존. body: ListView( controller: _scroll, physics: physics, - padding: const EdgeInsets.only(bottom: 24), + padding: EdgeInsets.zero, children: [ - AspectRatio( - aspectRatio: kPostImageAspectRatio, - child: hasPhoto ? _photoHero(r) : _blobHero(r), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _infoChildren(r, contentInHero: !hasPhoto), - ), + SizedBox( + height: MediaQuery.sizeOf(context).height, + child: _hero(context), ), ], ), @@ -298,231 +414,532 @@ class _ReviewDetailScreenState extends State { ); } - Widget _photoHero(ReviewCardData r) { - return Stack( - fit: StackFit.expand, - children: [ - Image.network( - r.photoUrls.first, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => - ColoredBox(color: context.colors.surfaceMuted), - ), - // 상태바 스크림 — 어두운 사진에서도 시간·배터리가 읽히게(게시글과 동일). - Positioned( - top: 0, - left: 0, - right: 0, - height: MediaQuery.paddingOf(context).top + 24, - child: const IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.white70, Colors.transparent], + Widget _hero(BuildContext context) { + // 현재 페이지가 영상이면 그 컨트롤러의 재생 상태에 반응. + final cur = _videoCtrls[_page]; + if (cur != null) { + return ValueListenableBuilder( + valueListenable: cur, + builder: (context, v, _) => _buildHero(context, v), + ); + } + return _buildHero(context, null); + } + + Widget _buildHero(BuildContext context, VideoPlayerValue? v) { + final r = widget.review; + final hasMedia = _pages.isNotEmpty; + final content = (r.content ?? '').trim(); + return LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + // 축소 전환 중엔 후기 그리드 타일과 같은 정사각(1:1) 상단 박스로. + final boxH = _mirror ? w : h; + final expanded = _expanded && !_mirror; + final safeBottom = MediaQuery.paddingOf(context).bottom; + final curIsVideo = _videoCtrls.containsKey(_page); + return Stack( + fit: StackFit.expand, + children: [ + // 미디어 — 풀스크린 페이저, 축소 전환 중엔 정사각 상단 박스. + AnimatedPositioned( + duration: _anchorDuration, + curve: Curves.easeOutCubic, + top: 0, + left: 0, + right: 0, + height: boxH, + child: ClipRect( + child: hasMedia + ? PageView( + controller: _pageCtrl, + onPageChanged: _onPageChanged, + children: [ + for (var i = 0; i < _pages.length; i++) + _mediaPage(context, i), + ], + ) + : Stack( + fit: StackFit.expand, + children: [ + // 미디어 없는 후기 — 카드의 블롭 배경 + 본문 센터 + // (게시글 블롭 글과 동일 문법, 탭으로 펼침/접힘). + BlobBackground( + seed: + r.seed ?? + '${r.author}/${r.createdAt?.millisecondsSinceEpoch}', + color: context.colors.primary, + ), + Positioned.fill( + child: BlobHeroContent( + content: content.isEmpty ? '내용 없는 후기' : content, + mirror: false, + expanded: expanded, + expandedMaxHeight: h * 0.55, + anchorDuration: _anchorDuration, + onToggle: _mirror + ? null + : () => + setState(() => _expanded = !_expanded), + ), + ), + ], + ), + ), + ), + // 하단 오버레이 패널 — 카드 미러 정보 + 그 뒤에만 σ8 사본 블러·스크림. + // 후기 타일에는 이런 패널이 없으므로 축소 전환 중엔 페이드아웃. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedOpacity( + opacity: _mirror ? 0 : 1, + duration: const Duration(milliseconds: 150), + child: MediaOverlayPanel( + blurSource: _blurSource(context, v), + blurSourceSize: Size(w, boxH), + bottomClearance: safeBottom + (curIsVideo ? 24 : 10), + clearanceDuration: _anchorDuration, + child: _ReviewInfoOverlay( + review: r, + showContent: hasMedia && content.isNotEmpty, + expanded: expanded, + expandedMaxHeight: h * 0.5, + onToggleExpand: _mirror + ? null + : () => setState(() => _expanded = !_expanded), + commentCount: _reviewId == null ? null : _comments.length, + onComments: _reviewId == null ? null : _openComments, + onAuthorTap: + (r.authorUserId == null || r.authorUserId!.isEmpty) + ? null + // 방문 후기는 개인 활동 — 항상 개인 얼굴. + : () => _openAuthorProfile( + r.authorUserId!, + r.author, + business: false, + ), + ), ), ), ), - ), - ), - ], + // 페이지 인디케이터(1/N) — 상단 중앙, 축소 전환 중 페이드아웃. + if (_pages.length > 1) + Positioned( + top: MediaQuery.paddingOf(context).top + 10, + left: 0, + right: 0, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _mirror ? 0 : 1, + duration: const Duration(milliseconds: 120), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0x66000000), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + '${_page + 1}/${_pages.length}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + // 재생 진행바 — 현재 페이지가 영상일 때만(게시글과 동일 문법). + if (v != null && v.isInitialized && !_videoErrors.contains(_page)) + Positioned( + left: 0, + right: 0, + bottom: safeBottom + 2, + child: AnimatedOpacity( + opacity: _mirror ? 0 : 1, + duration: const Duration(milliseconds: 120), + child: SizedBox( + height: 18, + child: VideoProgressIndicator( + _videoCtrls[_page]!, + allowScrubbing: true, + colors: VideoProgressColors( + playedColor: context.colors.primary, + bufferedColor: Colors.white38, + backgroundColor: Colors.white24, + ), + // 시각적 트랙은 하단 ~3.5px, 위쪽은 스크럽 터치 여유. + padding: const EdgeInsets.only(top: 14.5), + ), + ), + ), + ), + // 상태바 스크림 — 어두운 미디어에서도 시간·배터리가 읽히게. + if (hasMedia) + Positioned( + top: 0, + left: 0, + right: 0, + height: MediaQuery.paddingOf(context).top + 24, + child: const IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white70, Colors.transparent], + ), + ), + ), + ), + ), + ], + ); + }, ); } - /// 사진 없는 후기 — 카드와 같은 블롭 배경 + 본문. 축소 시 카드와 겹쳐진다. - Widget _blobHero(ReviewCardData r) { - return Stack( - fit: StackFit.expand, - children: [ - BlobBackground( - seed: r.seed ?? '${r.author}/${r.createdAt?.millisecondsSinceEpoch}', - color: context.colors.primary, - ), - Positioned.fill( - child: Padding( - padding: const EdgeInsets.fromLTRB(22, 24, 22, 24), - child: Center( - child: Text( - (r.content ?? '').isEmpty ? '내용 없는 후기' : r.content!, - textAlign: TextAlign.center, - maxLines: 9, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: context.colors.textPrimary, - height: 1.6, + /// 페이지 하나 — 사진(cover) 또는 영상(현재 페이지만 자동재생, 탭 토글). + Widget _mediaPage(BuildContext context, int i) { + final page = _pages[i]; + final photo = page.photoUrl; + if (photo != null) { + return Image.network( + photo, + fit: BoxFit.cover, + cacheWidth: 1200, + errorBuilder: (_, _, _) => + ColoredBox(color: context.colors.surfaceMuted), + ); + } + final video = page.video!; + final ctrl = _videoCtrls[i]!; + if (_videoErrors.contains(i)) { + return const ColoredBox( + color: kVideoFallbackBg, + child: Center(child: VideoErrorLabel()), + ); + } + return ValueListenableBuilder( + valueListenable: ctrl, + builder: (context, v, _) { + // 세로·정방형은 cover(풀스크린), 가로 영상은 검정 위 contain. + final fit = v.isInitialized && v.aspectRatio > 1 && !_mirror + ? BoxFit.contain + : BoxFit.cover; + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.black), // letterbox 여백 + if (v.isInitialized) + FittedBox( + fit: fit, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: v.size.width, + height: v.size.height, + child: VideoPlayer(ctrl), ), ), + // 포스터 — 초기화 전 cover 로 채우고, 준비되면 페이드아웃. + IgnorePointer( + child: AnimatedOpacity( + opacity: v.isInitialized ? 0 : 1, + duration: const Duration(milliseconds: 250), + child: video.thumbUrl != null + ? Image.network( + video.thumbUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const ColoredBox(color: kVideoFallbackBg), + ) + : const ColoredBox(color: kVideoFallbackBg), + ), ), - ), - ), - ], + if (!v.isInitialized) + const Center( + child: CircularProgressIndicator(color: Colors.white70), + ), + // 탭 = 재생/일시정지. 일시정지 시에만 중앙 ▶(쇼츠 문법). + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => toggleVideoPlayback(ctrl), + child: AnimatedOpacity( + opacity: v.isInitialized && !v.isPlaying ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: const Center( + child: IgnorePointer(child: VideoPlayBadge(size: 56)), + ), + ), + ), + ), + ], + ); + }, ); } - /// 본문 정보 위젯들 — 게시글 상세의 칩→제목→작성자→본문 순서를 후기 문법으로: - /// 방문 차수 칩 → 별점(제목 자리) → 작성자·날짜 → 본문 → 나머지 사진. - List _infoChildren(ReviewCardData r, {required bool contentInHero}) { - final showContent = - (r.content ?? '').isNotEmpty && - (!contentInHero || !_heroHoldsFullContent); - return [ - if (r.visitNo != null || r.isMine || r.hasIncentive) - Align( - alignment: Alignment.centerLeft, - child: Wrap( - spacing: 6, - runSpacing: 6, + /// 패널 블러의 원본 사본 — 현재 페이지의 미디어와 동일한 서브트리. + /// 블롭(미디어 없음)은 null — 카드처럼 블러 없이 스크림만. + Widget? _blurSource(BuildContext context, VideoPlayerValue? v) { + if (_pages.isEmpty) return null; + final page = _pages[_page]; + final photo = page.photoUrl; + if (photo != null) { + // 사진 — 카드의 블러 사본과 동일(저해상 디코딩으로 충분). + return Image.network( + photo, + fit: BoxFit.cover, + cacheWidth: 400, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ); + } + if (_videoErrors.contains(_page)) { + return const ColoredBox(color: kVideoFallbackBg); + } + if (v != null && v.isInitialized) { + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.black), + FittedBox( + fit: v.aspectRatio > 1 && !_mirror ? BoxFit.contain : BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: v.size.width, + height: v.size.height, + child: VideoPlayer(_videoCtrls[_page]!), + ), + ), + ], + ); + } + final thumb = page.video?.thumbUrl; + return thumb != null + ? Image.network( + thumb, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const ColoredBox(color: kVideoFallbackBg), + ) + : const ColoredBox(color: kVideoFallbackBg); + } +} + +/// 후기 하단 정보 오버레이 — 게시글 카드 미러(PostCardInfoOverlay)와 같은 +/// 타이포·간격을 후기 문법으로: 업체 혜택 배지(표시광고법 — 반드시 노출)·방문 +/// 차수·날짜 행, 별점(제목 자리), 본문(탭 확장), 닉네임·댓글 행. +class _ReviewInfoOverlay extends StatelessWidget { + final ReviewCardData review; + final bool showContent; + final bool expanded; + final double? expandedMaxHeight; + final VoidCallback? onToggleExpand; + + /// 댓글 수(null 이면 댓글 없음 — 아이콘 미표시). + final int? commentCount; + final VoidCallback? onComments; + final VoidCallback? onAuthorTap; + + const _ReviewInfoOverlay({ + required this.review, + required this.showContent, + required this.expanded, + this.expandedMaxHeight, + this.onToggleExpand, + this.commentCount, + this.onComments, + this.onAuthorTap, + }); + + /// 흰 필름 알약 위에서도 읽히는 진한 앰버(카드 이동 태그와 동일 톤). + static const _warnDark = Color(0xFF8F6E2F); + + Widget _pill(String label, {required Color fg}) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: fg), + ), + ); + + @override + Widget build(BuildContext context) { + final r = review; + final d = r.createdAt; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (r.visitNo != null) - _chip( - '${r.visitNo}번째 방문', - fg: context.colors.primaryDark, - bg: context.colors.primary.withValues(alpha: 0.12), - ), - if (r.isMine) - _chip( - '내 후기', - fg: context.colors.textSecondary, - bg: context.colors.surfaceMuted, + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + // 표시광고법: 경제적 이해관계 표시 — 항상 노출. + if (r.hasIncentive) _pill('업체 혜택 받고 작성한 후기', fg: _warnDark), + if (r.visitNo != null) + _pill( + '${r.visitNo}번째 방문', + fg: context.colors.primaryDark, + ), + if (r.isMine) + _pill('내 후기', fg: context.colors.textSecondary), + ], ), - // 표시광고법: 경제적 이해관계 표시 — 카드 축약 배지의 전체 문구. - if (r.hasIncentive) - _chip( - '업체 혜택 받고 작성한 후기', - fg: context.colors.textSecondary, - bg: context.colors.surfaceMuted, + ), + if (d != null) ...[ + const SizedBox(width: 6), + Text( + '${d.year}.${d.month}.${d.day}', + style: const TextStyle( + fontSize: 12, + color: Color(0xCCFFFFFF), + ), ), + ], ], ), - ), - if (r.visitNo != null || r.isMine || r.hasIncentive) - const SizedBox(height: 14), - // 별점 — 게시글의 제목 자리. - Row( - children: [ - for (var i = 0; i < 5; i++) - Icon( - i < r.rating ? Icons.star_rounded : Icons.star_outline_rounded, - size: 26, - color: i < r.rating - ? const Color(0xFFFFB300) - : context.colors.border, - ), - const SizedBox(width: 8), - Text( - '${r.rating}.0', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), + const SizedBox(height: 10), + // 별점 — 게시글 제목 자리. + Row( + children: [ + for (var i = 0; i < 5; i++) + Icon( + i < r.rating + ? Icons.star_rounded + : Icons.star_outline_rounded, + size: 21, + color: i < r.rating + ? const Color(0xFFFFB300) + : Colors.white38, + ), + const SizedBox(width: 6), + Text( + '${r.rating}.0', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], ), - ], - ), - const SizedBox(height: 14), - // 작성자 행 — 닉네임 + 작성일(팔로우 등은 후기에 없음). - // 닉네임 탭 → 작성자 프로필(아래에서 떠오르고 쓸어내려 닫기). - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: (r.authorUserId == null || r.authorUserId!.isEmpty) - ? null - : () => _openAuthorProfile( - r.authorUserId!, - r.author, - // 방문 후기는 개인 활동 — 항상 개인 얼굴. - business: false, - ), - child: Text( - r.author, - maxLines: 1, + if (showContent) ...[ + const SizedBox(height: 4), + if (onToggleExpand == null) + Text( + r.content!, + maxLines: 2, overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, + style: const TextStyle( + fontSize: 13, + color: Color(0xE0FFFFFF), + height: 1.5, ), + ) + else + // 본문 탭 = 펼침/접힘(게시글과 동일 — 블러 패널이 함께 자란다). + ExpandableOverlayText( + content: r.content!, + previewLines: 2, + expanded: expanded, + expandedMaxHeight: expandedMaxHeight, + onToggle: onToggleExpand!, ), - ), - ), - if (r.createdAt != null) - Text( - '${r.createdAt!.year}.${r.createdAt!.month}.${r.createdAt!.day}', - style: TextStyle( - fontSize: 12.5, - color: context.colors.textTertiary, + ], + const SizedBox(height: 12), + Row( + children: [ + // 닉네임 탭 → 작성자 프로필(게시글 오버레이와 동일 문법). + GestureDetector( + onTap: onAuthorTap, + child: Text( + r.author, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xE6FFFFFF), + ), + ), ), - ), + const Spacer(), + if (commentCount != null) + GestureDetector( + onTap: onComments, + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.chat_bubble_outline, + size: 16, + color: Color(0xCCFFFFFF), + ), + const SizedBox(width: 4), + Text( + '$commentCount', + style: const TextStyle( + fontSize: 12, + color: Color(0xCCFFFFFF), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), ], ), - if (showContent) ...[ - const SizedBox(height: 20), - Divider(height: 1, color: context.colors.border), - const SizedBox(height: 20), - Text( - r.content!, - style: TextStyle( - fontSize: 15, - color: context.colors.textPrimary, - height: 1.7, - ), - ), - ], - // 나머지 사진(히어로에 쓴 첫 장 제외) — 본문 아래에 이어서. - for (final url in r.photoUrls.skip(1)) ...[ - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.network( - url, - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), - ), - ], - // 첨부 영상 — 포스터 + ▶, 탭하면 전체화면 플레이어. - for (final v in r.videos) ...[ - const SizedBox(height: 12), - AspectRatio( - aspectRatio: 16 / 9, - child: VideoPosterTile( - videoUrl: v.url, - posterUrl: v.thumbUrl, - borderRadius: BorderRadius.circular(14), - cacheWidth: 1000, - ), - ), - ], - // 댓글 — 게시글 상세와 동일 문법(목록 + 하단 입력 바). - if (_reviewId != null) ...[ - const SizedBox(height: 28), - Text( - '댓글 ${_comments.length}', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), - ), - const SizedBox(height: 12), - _commentSection(), - ], - ]; + ); } +} + +/// 후기 댓글 리스트(시트 본문) — 로딩/빈 상태/행. 내 댓글 길게 누르면 삭제, +/// 닉네임 탭 → 프로필(업체 모드 댓글은 업체 얼굴). +class _ReviewCommentList extends StatelessWidget { + final bool loading; + final List comments; + final void Function(FacilityReviewComment) onDelete; + final void Function(String userId, String nickname, {required bool business}) + onOpenProfile; + + const _ReviewCommentList({ + required this.loading, + required this.comments, + required this.onDelete, + required this.onOpenProfile, + }); - Widget _commentSection() { - if (_loadingComments) { + @override + Widget build(BuildContext context) { + if (loading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center(child: CircularProgressIndicator()), ); } - if (_comments.isEmpty) { + if (comments.isEmpty) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 32), @@ -540,22 +957,21 @@ class _ReviewDetailScreenState extends State { } return Column( children: [ - for (final c in _comments) + for (final c in comments) Padding( padding: const EdgeInsets.only(bottom: 16), child: GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: c.isMine ? () => _confirmDeleteComment(c) : null, + onLongPress: c.isMine ? () => onDelete(c) : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - // 닉네임 탭 → 작성자 프로필(업체 모드 댓글은 업체 얼굴로). GestureDetector( onTap: c.userId.isEmpty ? null - : () => _openAuthorProfile( + : () => onOpenProfile( c.userId, c.authorNickname, business: c.authoredAs == 'business', @@ -595,97 +1011,4 @@ class _ReviewDetailScreenState extends State { ], ); } - - /// 하단 댓글 입력 바 — 게시글 상세 _BottomBar 의 입력부와 동일 문법(하트 없음). - Widget _commentInputBar() { - return Container( - decoration: BoxDecoration( - color: context.colors.surface, - border: Border( - top: BorderSide(color: context.colors.border, width: 0.5), - ), - ), - child: SafeArea( - top: false, - // 키보드가 올라오면 입력바가 그 위로 붙게 — bottomNavigationBar 는 - // viewInsets 를 자동 반영하지 않아 직접 더한다(입력 내용이 가려지던 문제). - child: Padding( - padding: EdgeInsets.fromLTRB( - 12, - 8, - 12, - 8 + MediaQuery.viewInsetsOf(context).bottom, - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _commentCtrl, - minLines: 1, - maxLines: 4, - decoration: InputDecoration( - hintText: '댓글을 입력하세요', - filled: true, - fillColor: context.colors.surfaceMuted, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide( - color: context.colors.primary, - width: 1.2, - ), - ), - ), - ), - ), - const SizedBox(width: 6), - Container( - decoration: BoxDecoration( - color: context.colors.primaryDark, - shape: BoxShape.circle, - ), - child: IconButton( - icon: _sending - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.textOnPrimary, - ), - ) - : Icon( - Icons.arrow_upward_rounded, - color: context.colors.textOnPrimary, - ), - onPressed: _sending ? null : _sendComment, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _chip(String label, {required Color fg, required Color bg}) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(100), - ), - child: Text( - label, - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fg), - ), - ); - } } diff --git a/lib/widgets/comments_sheet.dart b/lib/widgets/comments_sheet.dart new file mode 100644 index 0000000..f9d3f73 --- /dev/null +++ b/lib/widgets/comments_sheet.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; + +import '../services/keyboard_barrier.dart'; +import '../theme/app_palette.dart'; + +/// 댓글 바텀시트 열기 — 전역 키보드 배리어를 시트가 열려 있는 동안 끈다. +/// 배리어가 켜져 있으면 키보드가 뜬 상태의 첫 탭(전송 버튼 포함)을 흡수해 +/// "한 번 닫혀야 눌리는" 문제가 생긴다. 리스트 탭으로 키보드 닫기는 +/// [CommentsSheetShell] 내부에서 처리하고, 닫힐 때 배리어 복구 + 포커스 해제. +Future showCommentsSheet( + BuildContext context, { + required WidgetBuilder builder, +}) { + keyboardBarrierEnabled.value = false; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: context.colors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: builder, + ).whenComplete(() { + keyboardBarrierEnabled.value = true; + FocusManager.instance.primaryFocus?.unfocus(); + }); +} + +/// 댓글 바텀시트 공용 셸 — 그랩바·카운트 헤더 + 댓글 리스트(드래그 확장) + +/// 하단 입력바. 게시글 상세와 시설 후기 상세가 공유한다. +/// +/// - [listenable] 이 갱신되면 헤더 카운트·리스트·전송 스피너를 다시 그린다. +/// - 입력 포커스 중에는 시트 확장/축소 드래그를 잠근다(작성 중 오작동 방지). +/// - 리스트 빈 곳 탭 → 키보드 닫기(전역 배리어는 [showCommentsSheet] 가 끔). +class CommentsSheetShell extends StatefulWidget { + /// 카운트·리스트·전송 상태의 소스(예: PostDetailState, 후기 갱신 노티파이어). + final Listenable? listenable; + + /// 헤더 제목(예: '댓글 3') — 리빌드 시점마다 재평가. + final String Function() title; + + /// 댓글 리스트 본문(비스크롤 — 스크롤은 시트가 담당). + final WidgetBuilder listBuilder; + + final TextEditingController inputController; + final bool Function() sending; + final VoidCallback onSend; + + /// 입력바 표시 여부 — 비로그인 열람(후기) 등에서 숨긴다. + final bool showInput; + + const CommentsSheetShell({ + super.key, + this.listenable, + required this.title, + required this.listBuilder, + required this.inputController, + required this.sending, + required this.onSend, + this.showInput = true, + }); + + @override + State createState() => _CommentsSheetShellState(); +} + +class _CommentsSheetShellState extends State { + /// 입력 포커스 추적 — 작성 중에는 시트 드래그(확장/축소)를 잠근다. + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _focus.addListener(_onFocus); + } + + void _onFocus() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _focus.removeListener(_onFocus); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final composing = _focus.hasFocus; + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.92, + builder: (context, scroll) { + Widget body(BuildContext context) => Column( + children: [ + const SizedBox(height: 10), + // 그랩바. + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.colors.border, + borderRadius: BorderRadius.circular(100), + ), + ), + const SizedBox(height: 14), + Text( + widget.title(), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: context.colors.textPrimary, + ), + ), + const SizedBox(height: 6), + Expanded( + // 리스트 빈 곳 탭 → 키보드 닫기(전역 배리어는 시트 동안 꺼짐). + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: ListView( + controller: scroll, + // 작성 중엔 시트 확장/축소 드래그 잠금(스크롤 물리 차단 — + // DraggableScrollableSheet 는 이 스크롤러블로만 움직인다). + physics: composing + ? const NeverScrollableScrollPhysics() + : null, + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + children: [widget.listBuilder(context)], + ), + ), + ), + if (widget.showInput) _inputBar(context), + ], + ); + final listenable = widget.listenable; + if (listenable == null) return body(context); + return ListenableBuilder( + listenable: listenable, + builder: (context, _) => body(context), + ); + }, + ); + } + + /// 입력바 — 키보드가 올라오면 그 위로 붙는다. + Widget _inputBar(BuildContext context) { + final sending = widget.sending(); + return Container( + decoration: BoxDecoration( + color: context.colors.surface, + border: Border( + top: BorderSide(color: context.colors.border, width: 0.5), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + 12, + 8, + 12, + 8 + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: widget.inputController, + focusNode: _focus, + minLines: 1, + maxLines: 4, + decoration: InputDecoration( + hintText: '댓글을 입력하세요', + filled: true, + fillColor: context.colors.surfaceMuted, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide( + color: context.colors.primary, + width: 1.2, + ), + ), + ), + ), + ), + const SizedBox(width: 6), + Container( + decoration: BoxDecoration( + color: context.colors.primaryDark, + shape: BoxShape.circle, + ), + child: IconButton( + icon: sending + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.textOnPrimary, + ), + ) + : Icon( + Icons.arrow_upward, + color: context.colors.textOnPrimary, + ), + onPressed: sending ? null : widget.onSend, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index 3920c2c..e8f5391 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -275,7 +275,7 @@ class PostCardInfoOverlay extends StatelessWidget { style: _contentStyle, ) else - _ExpandableContent( + ExpandableOverlayText( content: post.content, previewLines: previewLines, expanded: expanded, @@ -372,9 +372,10 @@ class PostCardInfoOverlay extends StatelessWidget { } } -/// 펼치기/접기 가능한 본문 — 미리보기 줄수를 넘치면 "더보기" 버튼을 붙이고, -/// 펼침/접힘의 높이 변화를 AnimatedSize(스프링 커브)로 재생한다. -class _ExpandableContent extends StatelessWidget { +/// 펼치기/접기 가능한 본문(오버레이 공용 — 게시글·시설 후기 상세) — +/// 미리보기 줄수를 넘치면 말줄임(…)으로 접히고 본문 탭으로 토글하며, +/// 높이 변화를 AnimatedSize(스프링 커브)로 재생한다. +class ExpandableOverlayText extends StatelessWidget { final String content; final int previewLines; final bool expanded; @@ -383,7 +384,8 @@ class _ExpandableContent extends StatelessWidget { final double? expandedMaxHeight; final VoidCallback onToggle; - const _ExpandableContent({ + const ExpandableOverlayText({ + super.key, required this.content, required this.previewLines, required this.expanded, diff --git a/lib/widgets/post_media_hero.dart b/lib/widgets/post_media_hero.dart index b73a9e4..7884dec 100644 --- a/lib/widgets/post_media_hero.dart +++ b/lib/widgets/post_media_hero.dart @@ -158,7 +158,7 @@ class _PostMediaHeroState extends State { // 블롭 글 — 카드처럼 본문이 히어로의 중심(탭으로 펼침/접힘). if (isBlob) Positioned.fill( - child: _BlobContent( + child: BlobHeroContent( content: widget.post.content, mirror: _mirror, expanded: expanded, @@ -179,7 +179,7 @@ class _PostMediaHeroState extends State { left: 0, right: 0, bottom: _mirror ? h - cardH : 0, - child: _OverlayPanel( + child: MediaOverlayPanel( // 블러 사본 — 카드와 동일한 방식(같은 소스를 한 겹 더 그려 // σ8 블러 + 마스크). 미디어 박스 하단 == 패널 하단이므로 // 바닥 정렬 OverflowBox 로 뒤 픽셀과 정확히 겹친다. @@ -389,10 +389,10 @@ class _PostMediaHeroState extends State { } } -/// 블롭 글의 센터 본문 — 카드와 동일한 배치(축소 전환 중엔 카드의 하단 정보 +/// 블롭 글의 센터 본문(게시글·후기 상세 공용) — 카드와 동일한 배치(축소 전환 중엔 카드의 하단 정보 /// 여백 170 기준)로 전환을 잇고, 풀스크린에선 오버레이 패널 위 공간의 중심. /// 본문이 9줄을 넘치면 말줄임(…)으로 접히고 탭으로 펼침/접힘(내부 스크롤 제한). -class _BlobContent extends StatelessWidget { +class BlobHeroContent extends StatelessWidget { final String content; final bool mirror; final bool expanded; @@ -400,7 +400,8 @@ class _BlobContent extends StatelessWidget { final Duration anchorDuration; final VoidCallback? onToggle; - const _BlobContent({ + const BlobHeroContent({ + super.key, required this.content, required this.mirror, required this.expanded, @@ -480,12 +481,13 @@ class _BlobContent extends StatelessWidget { } } -/// 오버레이 뒤에만 깔리는 점진 블러 패널 — 높이는 콘텐츠(카드 미러 오버레이)가 +/// 오버레이 뒤에만 깔리는 점진 블러 패널 — 게시글·시설 후기 상세가 공유한다. +/// 높이는 콘텐츠(카드 미러 오버레이)가 /// 결정한다. 블러는 피드 카드와 **같은 방식**: 같은 소스([blurSource])를 한 겹 /// 더 그려 σ8 로 블러하고 세로 그라데이션 마스크(dstIn)로 페이드인 — 마스크가 /// 연속이라 밴드·계단이 원천적으로 없고 블러 패스도 1번이다. 카드와 같은 /// 스크림을 겹쳐 가독을 보정한다. [blurSource] 가 null(블롭 글)이면 스크림만. -class _OverlayPanel extends StatelessWidget { +class MediaOverlayPanel extends StatelessWidget { final Widget child; /// 블러 사본의 원본 — 뒤에 깔린 미디어 박스와 동일한 서브트리. @@ -498,7 +500,8 @@ class _OverlayPanel extends StatelessWidget { final double bottomClearance; final Duration clearanceDuration; - const _OverlayPanel({ + const MediaOverlayPanel({ + super.key, required this.child, required this.blurSource, required this.blurSourceSize,