QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../core/data/server_clock.dart';
|
||||
|
||||
/// 타임어택 남은 시간 — 초마다 다시 그린다. 마감은 서버 값(`quests.activeTo`)이라
|
||||
/// 화면을 다시 열어도 처음부터 세지 않는다. 기준은 [ServerClock] 이다. 마감이 지나면
|
||||
/// [builder] 가 null 을 받고 타이머를 멈춘다.
|
||||
///
|
||||
/// 표시용이다. 참여 수락/거부는 서버 시각으로 다시 판정한다(`fn-quest-timeattack`).
|
||||
class TimeAttackCountdown extends StatefulWidget {
|
||||
const TimeAttackCountdown({
|
||||
super.key,
|
||||
required this.endsAt,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final DateTime endsAt;
|
||||
final Widget Function(BuildContext context, Duration? left) builder;
|
||||
|
||||
@override
|
||||
State<TimeAttackCountdown> createState() => _TimeAttackCountdownState();
|
||||
}
|
||||
|
||||
class _TimeAttackCountdownState extends State<TimeAttackCountdown> {
|
||||
late final Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final left = widget.endsAt.difference(ServerClock.now());
|
||||
final closed = left.isNegative || left == Duration.zero;
|
||||
if (closed) _timer.cancel();
|
||||
return widget.builder(context, closed ? null : left);
|
||||
}
|
||||
}
|
||||
|
||||
/// `H:MM:SS`(1시간 이상) 또는 `MM:SS`.
|
||||
String formatLeft(Duration left) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final h = left.inHours;
|
||||
final m = two(left.inMinutes % 60);
|
||||
final s = two(left.inSeconds % 60);
|
||||
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||
}
|
||||
Reference in New Issue
Block a user