import 'dart:async'; import 'package:flutter/material.dart'; import '../../core/data/backend.dart'; import '../../core/data/server_clock.dart'; import '../../core/monetization/rewarded_ad.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; import '../home/home_screen.dart' show formatPop; import '../planet/fpi_ranking_screen.dart' show MockBadge; import '../planet/moderation.dart'; import 'quest_ad_screens.dart'; import 'quest_create_screen.dart' show QuestLinkCard; import 'quest_data.dart'; import 'quest_evidence.dart'; import 'quest_reward_screen.dart'; import 'widgets/quest_card_tile.dart'; import 'widgets/time_attack_countdown.dart'; import '../../core/i18n/app_locale.dart'; /// 퀘스트 상세 — 화면 대조 원장 F4. /// /// Figma 노드 /// - `264:48835` 홈/퀘스트/퀘스트 상세 · `272:245274` 홈_퀘스트/퀘스트 상세 /// - `494:14332` 광고 시청 전 · `482:15084` 광고 시청 후 /// - `548:21261` 신고하기 /// /// 명세 `518:88441` — 상태별 버튼과 동작이 전부 정해져 있다. /// *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 수집 ·* /// *「진행중」 퀘스트 포기 알림 노출 · 「보상받기」 관리자 퀘스트 경우 추가 보상 모달* class QuestDetailScreen extends StatefulWidget { const QuestDetailScreen({ super.key, required this.questId, required this.repository, }); final String questId; /// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다. final QuestRepository repository; @override State createState() => _QuestDetailScreenState(); } class _QuestDetailScreenState extends State { QuestDetail? _detail; QuestStage? _stage; /// 실측 진행도. 명세 `518:88441` 의 *실시간 목표 실행률 수집* 이다. /// null 이면 아직 수집을 시작하지 않아 서버가 준 값을 그린다. int? _live; StreamSubscription? _walk; /// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다. bool _busy = false; @override void initState() { super.initState(); _load(); } @override void dispose() { _walk?.cancel(); super.dispose(); } /// 실측 수집을 끈다. 해지 완료를 기다리지 않는다 — 기다릴 이유가 없고, /// 끝난 스트림에서는 그 Future 가 늦게 완료된다. void _stopWatching() { unawaited(_walk?.cancel()); _walk = null; } Future _load() async { final detail = await widget.repository.detail(widget.questId); if (!mounted) return; setState(() { _detail = detail; _stage = detail.card.stage; }); // 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면 // 상세를 열어도 실행률이 0 에 멈춘 채로 남는다. if (_stage == QuestStage.inProgress && detail.card.type == QuestType.steps) { _watch(detail.card); } } @override Widget build(BuildContext context) { final detail = _detail; final stage = _stage; if (detail == null || stage == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final card = detail.card; final image = questImage(card); final blocked = card.type.blockedReason; final submission = detail.submission; final ugc = card.author == QuestAuthor.user; final endsAt = card.endsAt; return Scaffold( appBar: AppBar( actions: [ PopupMenuButton( tooltip: context.l10n.commonMore, icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24, color: AppColors.onCosmic), onSelected: (value) => value == 'edit' ? _edit(detail) : _report(card.title), itemBuilder: (_) => [ // 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07). if (stage == QuestStage.mine && ugc) PopupMenuItem(value: 'edit', child: Text(context.l10n.questEdit)), PopupMenuItem(value: 'report', child: Text(context.l10n.reportTitle)), ], ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), children: [ // 명세 `518:88429` — 대표 이미지 · 카테고리 · 퀘스트 명 · 중앙 정렬. // 시안 `264:48835` — #2e2e2e 240 원 안에 200 이미지. Center( child: Container( width: 240, height: 240, alignment: Alignment.center, decoration: const BoxDecoration( color: Color(0xFF2E2E2E), shape: BoxShape.circle, ), child: image == null ? null : Image.asset(image, width: 200, height: 200), ), ), const SizedBox(height: 24), Text( card.categoryName, textAlign: TextAlign.center, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w400, height: 20 / 15, color: Color(0xFF777777), ), ), const SizedBox(height: 8), Text( card.title, textAlign: TextAlign.center, style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ), ), const SizedBox(height: 24), // 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10). if (detail.link != null) ...[ QuestLinkCard(link: detail.link!), const SizedBox(height: 12), ], if (ugc && detail.description.isNotEmpty) ...[ Text(context.l10n.questAuthorNote, style: _caption), const SizedBox(height: 4), Text(detail.description, style: _body), const SizedBox(height: 24), ], // 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP. _Row( label: context.l10n.questGoal, value: '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}', ), const SizedBox(height: 8), _Row( label: context.l10n.questReward, bolt: card.rewardPop != null, value: card.rewardPop == null ? context.l10n.questPopGrant : '+${formatPop(card.rewardPop!)}', ), // 타임어택 — 남은 시간. 마감 판정은 서버 시각이 다시 한다(FR-QST-06). if (endsAt != null) ...[ const SizedBox(height: 8), TimeAttackCountdown( endsAt: endsAt, builder: (context, left) => _Row( label: context.l10n.questTimeLeft, value: left == null ? context.l10n.questTimeAttackClosed : formatLeft(left), ), ), ], const SizedBox(height: 8), // 마감 순간은 서버값 그대로, 표시만 기기 시간대(C-06 · G-04). if (detail.endsAt != null) ...[ _Row(label: context.l10n.questDeadline, value: _deadline(detail.endsAt!)), const SizedBox(height: 8), ], // 참여 전 간격 · 최대 횟수 안내(C-12). if (card.type == QuestType.place) Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Text( detail.repeatHours == null ? context.l10n.questRepeatOnce : context.l10n.questRepeatEvery('${detail.repeatHours}', '${detail.repeatMax}'), style: _body, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 24), child: Row( children: [ Image.asset('assets/figma/quest-check.png', width: 20, height: 20), const SizedBox(width: 8), Expanded(child: Text(context.l10n.questParticipants, style: _strong)), Text( card.capacity == null ? formatPop(card.participants) : '${formatPop(card.participants)} / ${formatPop(card.capacity!)}', style: _strong, ), ], ), ), // 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위. // 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다. if (blocked != null) MockBadge(note: blocked), if (submission != null) ..._submissionStatus(submission), if (detail.reviews.isNotEmpty) ..._reviewQueue(detail), ], ), // 시안 — 내가 만든 퀘스트는 하단 버튼이 없다(`264:48835`). 나머지는 하단 고정 56 버튼. bottomNavigationBar: stage == QuestStage.mine ? null : endsAt == null // 사용자 생성 Quest 는 서버 시각 기준 마감이 지나면 잠근다(C-06 · G-04). 판정은 서버가 다시 한다. ? _button(detail, stage, blocked, closed: detail.endsAt != null && !ServerClock.now().isBefore(detail.endsAt!)) : TimeAttackCountdown( endsAt: endsAt, builder: (_, left) => _button(detail, stage, blocked, closed: left == null), ), ); } /// 하단 고정 56 버튼. 타임어택은 마감([closed])되면 잠근다. Widget _button( QuestDetail detail, QuestStage stage, String? blocked, { required bool closed, }) { final colors = questButtonColors(stage); final open = !closed || stage == QuestStage.rewarded; final submission = detail.submission; // 사람 검토형의 제출 상태(C-08 · G-03) — 검토 중 · 최종 반려는 다시 누를 수 없다. final held = submission != null && (submission.pending || submission.finalRejected); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( onPressed: stage.tappable && !_busy && blocked == null && open && !held ? () => _act(detail, stage) : null, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), backgroundColor: colors.bg, foregroundColor: colors.fg, disabledBackgroundColor: const Color(0xFF333333), disabledForegroundColor: const Color(0xFF777777), textStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w600, height: 24 / 16, ), ), child: Text( blocked != null ? '발주사 확인 중' : !open ? context.l10n.questTimeAttackClosed : _busy ? context.l10n.commonChecking : submission != null && submission.pending ? context.l10n.questReviewButton : submission != null && submission.rejected ? (!submission.finalRejected ? context.l10n.questResubmit : context.l10n.questRejectedButton) : detail.card.type.reviewed && stage == QuestStage.notStarted ? context.l10n.questSubmit : stage.buttonLabel, ), ), ), ); } static const _caption = TextStyle( fontSize: 13, fontWeight: FontWeight.w500, height: 18 / 13, color: Color(0xFF777777), ); static const _body = TextStyle( fontSize: 15, fontWeight: FontWeight.w400, height: 1.4, color: AppColors.onCosmic, ); static String _two(int n) => n.toString().padLeft(2, '0'); /// 기기 시간대의 마감 시각과 남은 시간. String _deadline(DateTime endsAt) { final local = endsAt.toLocal(); final at = '${local.year}.${_two(local.month)}.${_two(local.day)} ' '${_two(local.hour)}:${_two(local.minute)}'; final left = endsAt.difference(ServerClock.now()); return left.isNegative ? '$at · ${context.l10n.questEnded}' : '$at · ${context.l10n.questRemainingHours('${left.inHours}')}'; } /// 내 제출 건 안내(C-08) — 검토 중 · 지연 · 승인 후 지급 · 반려 사유와 재제출 가능 여부. List _submissionStatus(QuestSubmission s) { final lines = [ if (s.status == 'submitted') context.l10n.questReviewPending, if (s.delayed(ServerClock.now())) context.l10n.questReviewDelayed, if (s.status == 'approved') context.l10n.questApprovedPaying, if (s.rejected) ...[ context.l10n.questRejected(s.rejectReason ?? ''), !s.finalRejected ? context.l10n.questResubmitAllowed : context.l10n.questResubmitNotAllowed, ], ]; return [for (final line in lines) Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Text(line, style: _body), )]; } /// 생성자 검토 대기열 — 승인 · 반려. 시간이 지나도 자동 승인하지 않는다(C-08). List _reviewQueue(QuestDetail detail) => [ const SizedBox(height: 16), Text(context.l10n.questReviewQueue('${detail.reviews.length}'), style: _strong), for (final s in detail.reviews) ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4), title: Text(s.summary, style: _body), subtitle: s.delayed(ServerClock.now()) ? Text(context.l10n.questReviewDelayed, style: _caption) : null, trailing: Wrap( spacing: 4, children: [ TextButton( onPressed: _busy ? null : () => _review(s, approve: true), child: Text(context.l10n.questApprove), ), TextButton( onPressed: _busy ? null : () => _review(s, approve: false), child: Text(context.l10n.questReject), ), ], ), ), ]; Future _review(QuestSubmission s, {required bool approve}) async { String? reason; var resubmit = false; if (!approve) { final input = TextEditingController(); final ok = await showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialog) => AlertDialog( title: Text(context.l10n.questReject), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: input, decoration: InputDecoration(labelText: context.l10n.questRejectReason), ), CheckboxListTile( contentPadding: EdgeInsets.zero, value: resubmit, onChanged: (v) => setDialog(() => resubmit = v ?? false), title: Text(context.l10n.questAllowResubmit), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(context.l10n.questReject), ), ], ), ), ); reason = input.text.trim(); input.dispose(); if (ok != true || reason.isEmpty) return; } await _run(() => widget.repository.review( s.id, approve: approve, reason: reason, resubmitAllowed: resubmit, )); } /// 인증 제출 · 보완 제출(C-08 · G-01) — 링크 또는 내용 중 하나 이상. Future _submitProof(QuestDetail detail) async { final url = TextEditingController(); final text = TextEditingController(); final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.questSubmit), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: url, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: context.l10n.questEvidenceUrl), ), TextField( controller: text, maxLines: 3, minLines: 1, decoration: InputDecoration(labelText: context.l10n.questEvidenceText), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(context.l10n.commonSubmit), ), ], ), ); final proofUrl = url.text.trim(); final proofText = text.text.trim(); url.dispose(); text.dispose(); if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return; final done = await _run( () => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText), ); if (done && mounted) _toast(context.l10n.questSubmitted); } /// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다. Future _edit(QuestDetail detail) async { final title = TextEditingController(text: detail.card.title); final description = TextEditingController(text: detail.description); final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.questEdit), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text(context.l10n.questEditNotice, style: _caption), TextField( controller: title, decoration: InputDecoration(labelText: context.l10n.questName), ), TextField( controller: description, maxLines: 3, minLines: 1, decoration: InputDecoration(labelText: context.l10n.questDescriptionOptional), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(context.l10n.commonEdit), ), ], ), ); final newTitle = title.text.trim(); final newDescription = description.text.trim(); title.dispose(); description.dispose(); if (ok != true || newTitle.isEmpty) return; await _run(() => widget.repository.edit( detail.card.id, title: newTitle, description: newDescription, )); } /// 서버 호출 한 번 — 실패는 알리고, 성공하면 상세를 다시 읽는다. Future _run(Future Function() call) async { setState(() => _busy = true); try { await call(); } catch (error) { if (!mounted) return false; setState(() => _busy = false); _fail(error); return false; } if (!mounted) return false; setState(() => _busy = false); await _load(); return true; } static const _strong = TextStyle( fontSize: 15, fontWeight: FontWeight.w600, height: 20 / 15, color: AppColors.onCosmic, ); Future _act(QuestDetail detail, QuestStage stage) async { switch (stage) { case QuestStage.notStarted when detail.card.type.reviewed: await _submitProof(detail); case QuestStage.notStarted: // **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지 // 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다. if (detail.card.goal > 1 && detail.card.type == QuestType.steps) { await _start(detail); } else { await _claim(detail); } case QuestStage.inProgress: await _confirmStop(detail); case QuestStage.completed: await _claim(detail); case QuestStage.mine: _toast(context.l10n.questMine('${detail.card.participants}')); case QuestStage.rewarded: break; } } /// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다. Future _start(QuestDetail detail) async { final next = await widget.repository.advance(detail.card.id, QuestStage.inProgress); if (!mounted) return; setState(() { _stage = next; _live = 0; }); _watch(detail.card); } /// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다. /// /// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01), /// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다. void _watch(QuestCard card) { _walk?.cancel(); _walk = widget.repository.progressStream(card).listen( (value) { if (!mounted) return; setState(() { _live = value; if (card.goal > 0 && value >= card.goal) { _stage = QuestStage.completed; } }); }, onError: (Object error) { // 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시 // 시도할 수 있고, 미동의 상태에서도 화면은 그대로 쓸 수 있다(B-27). if (!mounted) return; setState(() { _stage = QuestStage.notStarted; _live = null; }); _fail(error); }, ); } /// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·* /// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트* Future _confirmStop(QuestDetail detail) async { final stop = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.questStopConfirm), content: Text(context.l10n.questStopBody), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(context.l10n.questStop), ), ], ), ); if (stop != true) return; _stopWatching(); final next = await widget.repository.advance(detail.card.id, QuestStage.notStarted); if (mounted) { setState(() { _stage = next; _live = null; }); } } /// 보상받기. /// /// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만 /// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다 /// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다. Future _claim(QuestDetail detail) async { if (_busy) return; final bonus = await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false; if (!mounted) return; // 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다. String? impression; if (bonus) { impression = await _watchAd(); if (!mounted) return; } // 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 — // 별첨 1 ② 「클라이언트 단독 지급 0건」. // 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) — // 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다. setState(() => _busy = true); final QuestReward reward; int? adPop; BackendException? adError; try { final pending = () async { final base = await widget.repository.verify(detail.card); if (impression != null && !base.duplicate) { try { adPop = await widget.repository.claimAdReward(impression, base); } on BackendException catch (error) { adError = error; } } return base; }(); // 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다. if (impression != null) { unawaited(_warp(QuestAdWarpScreen.after(until: pending))); } reward = await pending; } catch (error) { if (!mounted) return; setState(() => _busy = false); _fail(error); // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. if (error is BackendException && error.code == 'E-QST-01' && detail.card.type == QuestType.timeAttack) { await _load(); } return; } _stopWatching(); if (!mounted) return; setState(() { _busy = false; _stage = QuestStage.rewarded; }); // 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다. if (reward.duplicate) { _toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop))); await _load(); return; } // 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다. final failed = adError; if (failed != null) _toast(failed.message); await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop), ), ); if (!mounted) return; // 서버 상태를 다시 읽는다. 걸음 퀘스트는 구간을 하나 받아도 위 구간이 남아 있어 // (B-26 은 하루에 네 구간이다) 「보상완료」로 굳히면 남은 구간을 받을 수 없다. await _load(); } /// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다. /// /// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면 /// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 · /// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다. Future _watchAd() async { RewardedAdHandle? ad; BackendException? failure; final loading = widget.repository.loadRewardedAd().then( (handle) => ad = handle, onError: (Object error) => failure = error is BackendException ? error : BackendException('E-AD-01', context.l10n.adLoadFailed), ); await _warp(QuestAdWarpScreen.before(until: loading)); final loaded = ad; if (!mounted) { loaded?.dispose(); return null; } if (loaded == null) { final error = failure; if (error != null) { _toast(error.message); } else { // 건너뛰기 — 아직 로딩 중인 광고는 도착하는 대로 버린다. unawaited(loading.then((_) => ad?.dispose())); } return null; } final impression = await loaded.show(); if (impression == null && mounted) { _toast(context.l10n.adNotFinished); } return impression; } /// 실패를 사용자 말로 옮긴다. 분기는 에러 카탈로그 코드로 한다 — /// gRPC 상태는 여러 사유가 겹쳐 들어온다(`Backend.call`). void _fail(Object error) { final message = error is BackendException ? error.message : context.l10n.commonRequestFailed; final permission = error is BackendException && error.code == 'E-PERM-01'; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( content: Text(message), behavior: SnackBarBehavior.floating, action: permission ? SnackBarAction( label: context.l10n.openSettings, onPressed: openQuestPermissionSettings, ) : null, )); } Future _warp(QuestAdWarpScreen screen) => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute(builder: (_) => screen), ); Future _report(String title) async { final reason = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => ReportScreen( target: ModerationTarget(authorName: title, title: context.l10n.questNamed(title)), ), ), ); if (reason != null && mounted) _toast(context.l10n.reportReceived); } void _toast(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } } /// 목표·보상 줄 — #1a1a1c r24 · 높이 62 · 안쪽 20. class _Row extends StatelessWidget { const _Row({required this.label, required this.value, this.bolt = false}); final String label; final String value; /// 보상 POP 앞 번개(민트). final bool bolt; @override Widget build(BuildContext context) => Container( constraints: const BoxConstraints(minHeight: 62), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(24), ), child: Row( children: [ Expanded( child: Text( label, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, height: 20 / 14, color: AppColors.onCosmicVariant, ), ), ), if (bolt) ...[ Image.asset('assets/figma/icon-bolt.png', width: 16, height: 16.7), const SizedBox(width: 4), ], // 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게. Flexible( child: Text( value, textAlign: TextAlign.end, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, height: 1.4, letterSpacing: -0.4, color: AppColors.onCosmic, ), ), ), ], ), ); }