381 lines
13 KiB
Dart
381 lines
13 KiB
Dart
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) {
|
|
switch (card.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:
|
|
}
|
|
final title = card.title;
|
|
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';
|
|
}
|
|
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png';
|
|
return null;
|
|
}
|
|
|
|
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
|
|
class QuestIconBox extends StatelessWidget {
|
|
const QuestIconBox({super.key, required this.card});
|
|
|
|
final QuestCard card;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final image = questImage(card);
|
|
return Container(
|
|
width: 50,
|
|
height: 50,
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2E2E2E),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: image == null ? null : 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(card: 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(
|
|
child: _Stat(
|
|
label: context.l10n.questGoal,
|
|
value: card.goal > 0
|
|
? '${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,
|
|
),
|
|
),
|
|
child: Text(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(card: 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|