import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/app_icons.dart'; import '../../home/home_screen.dart' show formatPop; import '../quest_data.dart'; import '../../../core/i18n/app_locale.dart'; /// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세). /// /// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다. /// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다. String questImage(QuestCard card) => questImageFor(card.type, card.title); /// [questImage] 를 카드 없이 — HOME 「TODAY'S QUEST」 줄은 요약([QuestSummary])만 들고 있다. String questImageFor(QuestType? type, String title) { switch (type) { case QuestType.attend: return 'assets/figma/quest-img-stamp.png'; case QuestType.steps: return 'assets/figma/quest-img-shoe.png'; case QuestType.place: return 'assets/figma/quest-img-truck.png'; default: } if (title.contains('출석')) return 'assets/figma/quest-img-stamp.png'; if (title.contains('좋아요')) return 'assets/figma/quest-img-heart.png'; if (title.contains('댓글')) return 'assets/figma/quest-img-bubble.png'; if (title.contains('만보기') || title.contains('걷기')) { return 'assets/figma/quest-img-shoe.png'; } // 유형 · 제목으로 못 고르면 기본 아이콘(번개) — 빈 칸은 고장처럼 보인다(2026-10-04 실기기 4-9). return 'assets/figma/quest-img-bolt.png'; } /// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지. class QuestIconBox extends StatelessWidget { const QuestIconBox({super.key, required this.image}); /// 에셋 경로 — [questImage] · [questImageFor]. final String image; @override Widget build(BuildContext context) { return Container( width: 50, height: 50, padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: const Color(0xFF2E2E2E), borderRadius: BorderRadius.circular(16), ), child: Image.asset(image, width: 42, height: 42), ); } } /// 상태별 버튼 색 — 시안: 보상받기 핑크 · 진행중 #333333 · 시작하기/자세히 흰색 · /// 보상 완료 비활성. ({Color bg, Color fg}) questButtonColors(QuestStage stage) => switch (stage) { QuestStage.completed => (bg: AppColors.cosmicAccent, fg: Colors.black), QuestStage.inProgress => ( bg: const Color(0xFF333333), fg: AppColors.onCosmic, ), QuestStage.rewarded => ( bg: const Color(0xFF333333), fg: const Color(0xFF777777), ), _ => (bg: AppColors.onCosmic, fg: Colors.black), }; /// 목록의 퀘스트 카드 한 장 — 시안 `264:46867` 카드(#1a1a1c r12 · 안쪽 20). /// /// 명세 — *퀘스트 아이콘 · 퀘스트 명 · 퀘스트 카테고리 · 진행 상태 값 ·* /// *목표값 · 보상값 · 참여인원값 · 버튼(진행 상태값에 따른 아래 버튼 노출)*. class QuestCardTile extends StatelessWidget { const QuestCardTile({ super.key, required this.card, required this.onOpen, required this.onAction, this.onMore, this.preview = false, }); /// 생성 화면 미리보기(`254:164081`) — 버튼이 없고, 비어 있는 값은 회색 「-」다. const QuestCardTile.preview({super.key, required this.card}) : onOpen = null, onAction = null, onMore = null, preview = true; final QuestCard card; final VoidCallback? onOpen; final VoidCallback? onAction; final bool preview; /// 우측 상단 ⋮. 사용자가 만든 퀘스트에만 있다. final VoidCallback? onMore; @override Widget build(BuildContext context) { final colors = questButtonColors(card.stage); return Material( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.md), child: InkWell( onTap: onOpen, borderRadius: BorderRadius.circular(AppRadius.md), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ QuestIconBox(image: questImage(card)), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( card.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ), ), const SizedBox(height: 6), Text( card.categoryName, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w400, height: 1.4, color: AppColors.onCosmicVariant, ), ), ], ), ), if (onMore != null) ...[ const SizedBox(width: 10), GestureDetector( onTap: onMore, child: Image.asset( 'assets/figma/quest-kebab.png', width: 24, height: 24, ), ), ], ], ), SizedBox(height: preview ? 16 : 12), Row( children: [ Expanded( // Music Shake 는 Figma `480:14262` 「목표BPM 350」 — 곡 BPM 한 값이다. child: _Stat( label: card.type == QuestType.musicShake ? context.l10n.questGoalBpm : context.l10n.questGoal, value: card.goal <= 0 ? '-' : card.type == QuestType.musicShake ? formatPop(card.goal) : '${formatPop(card.progress)} / ${formatPop(card.goal)}', muted: card.goal <= 0, ), ), const SizedBox(width: 8), Expanded( child: _Stat( label: context.l10n.questReward, value: (card.rewardPop ?? 0) <= 0 && preview ? '-' : card.rewardPop == null ? '-' : formatPop(card.rewardPop!), unit: 'POP', muted: preview && (card.rewardPop ?? 0) <= 0, ), ), ], ), SizedBox(height: preview ? 16 : 12), Text( card.capacity == null ? context.l10n.questParticipantsCount( formatPop(card.participants), ) : context.l10n.questParticipantsCap( formatPop(card.participants), formatPop(card.capacity!), ), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, height: 1.3, letterSpacing: -0.3, color: preview ? AppColors.onCosmicMuted : AppColors.onCosmic, ), ), if (!preview) ...[ const SizedBox(height: 16), FilledButton( // 「보상완료」는 명세가 클릭 불가로 정했다. onPressed: card.stage.tappable ? onAction : null, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(37), padding: const EdgeInsets.symmetric(horizontal: 34), tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), backgroundColor: colors.bg, foregroundColor: colors.fg, disabledBackgroundColor: colors.bg, disabledForegroundColor: colors.fg, textStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w600, height: 1, ), ), // 내가 만든 Quest 가 검수 중 · 반려면 「자세히」 자리에 상태를 적는다(누르면 상세). child: Text(switch (card) { QuestCard(stage: QuestStage.mine, publishState: 'review') => context.l10n.questReviewButton, QuestCard(stage: QuestStage.mine, publishState: 'rejected') => context.l10n.questRejectedButton, _ => card.stage.buttonLabel, }), ), ], ], ), ), ), ); } } /// 목표·보상 칸 — #262626 r8 · 안쪽 8/12. class _Stat extends StatelessWidget { const _Stat({ required this.label, required this.value, this.unit, this.muted = false, }); final String label; final String value; final String? unit; /// 아직 값이 없는 칸 — 시안 #545454. final bool muted; static const _valueStyle = TextStyle( fontSize: 16, fontWeight: FontWeight.w600, height: 20 / 16, letterSpacing: -0.5, color: AppColors.onCosmic, ); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: const Color(0xFF262626), borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, height: 16 / 12, letterSpacing: -0.5, color: AppColors.onCosmicVariant, ), ), const SizedBox(height: 4), Row( children: [ Expanded( child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: muted ? _valueStyle.copyWith(color: const Color(0xFF545454)) : _valueStyle, ), ), if (unit != null) Text( unit!, style: _valueStyle.copyWith(color: const Color(0xFF2AC28B)), ), ], ), ], ), ); } /// 「오늘 꼭 해야하는 퀘스트에요」 줄 — 시안 `264:46867` 관리자 퀘스트(#1a1a1c · 높이 90). class QuestAdminRow extends StatelessWidget { const QuestAdminRow({ super.key, required this.card, required this.onOpen, this.divider = true, }); final QuestCard card; final VoidCallback onOpen; /// 아래 #242424 구분선. 마지막 줄은 없다. final bool divider; @override Widget build(BuildContext context) => Material( color: AppColors.cosmicSurfaceContainer, child: InkWell( onTap: onOpen, child: Container( constraints: const BoxConstraints(minHeight: 90), // 구분선 1 은 Container 가 안쪽 여백에 더하므로 아래에서 뺀다(줄 높이 90). padding: EdgeInsets.fromLTRB(20, 20, 20, divider ? 19 : 20), decoration: divider ? const BoxDecoration( border: Border(bottom: BorderSide(color: Color(0xFF242424))), ) : null, child: Row( children: [ QuestIconBox(image: questImage(card)), const SizedBox(width: 19), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( card.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, height: 20 / 18, color: AppColors.onCosmic, ), ), const SizedBox(height: 4), Text( card.categoryName, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, height: 20 / 14, color: AppColors.onCosmicVariant, ), ), ], ), ), const ImageIcon( AppIcons.chevronRight, size: 24, color: Color(0xFF4F4F4F), ), ], ), ), ), ); }