240 lines
8.4 KiB
Dart
240 lines
8.4 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../../core/theme/tokens.dart';
|
||
import '../home_data.dart';
|
||
import '../home_screen.dart' show formatPop;
|
||
import '../../../core/i18n/app_locale.dart';
|
||
import '../../quest/widgets/time_attack_countdown.dart';
|
||
|
||
/// TODAY'S QUEST 한 줄 — Figma 「오늘의 출석체크 / 출석 완료 / +{보상} / 보상받기」.
|
||
///
|
||
/// 보상 숫자는 Figma 의 50 이 아니라 서버 지급액과 같은 원천에서 온다.
|
||
///
|
||
/// 상태별 오른쪽 버튼이 다르다. 정책 설계서 §P-05 — 조건 미충족 시 보상은
|
||
/// 지급하지 않는다(`E-QST-01`).
|
||
class QuestRow extends StatelessWidget {
|
||
const QuestRow({super.key, required this.quest, this.onTap});
|
||
|
||
final QuestSummary quest;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
return Material(
|
||
color: AppColors.cosmicSurfaceContainer,
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpace.s4),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(quest.title, style: t.bodyMedium),
|
||
const SizedBox(height: AppSpace.s1),
|
||
Text(_subtitle(quest), style: t.bodySmall),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpace.s3),
|
||
Text(
|
||
_reward(quest),
|
||
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
|
||
),
|
||
const SizedBox(width: AppSpace.s3),
|
||
_Action(state: quest.state, onTap: onTap),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _subtitle(QuestSummary q) => switch (q.state) {
|
||
QuestState.claimable => appL10n.questAttended,
|
||
QuestState.inProgress => q.progressLabel ?? appL10n.questInProgressSpaced,
|
||
QuestState.done => appL10n.questRewardPaid,
|
||
};
|
||
}
|
||
|
||
class _Action extends StatelessWidget {
|
||
const _Action({required this.state, this.onTap});
|
||
|
||
final QuestState state;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
return switch (state) {
|
||
QuestState.claimable => FilledButton(
|
||
onPressed: onTap,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(0, AppSizing.touchMinApp),
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
backgroundColor: AppColors.cosmicAccent,
|
||
foregroundColor: AppColors.onCosmicAccent,
|
||
),
|
||
child: Text(_claimLabel),
|
||
),
|
||
QuestState.inProgress => OutlinedButton(
|
||
onPressed: onTap,
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(0, AppSizing.touchMinApp),
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
foregroundColor: AppColors.onCosmic,
|
||
side: const BorderSide(color: AppColors.cosmicOutline),
|
||
),
|
||
child: Text(_startLabel),
|
||
),
|
||
QuestState.done => Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
|
||
child: Text(_doneLabel, style: t.bodySmall),
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 사본 HOME 「TODAY'S QUEST」 버튼 문구(`252:159354` spec). 사본 HOME 에 없는 완료 상태는
|
||
/// 퀘스트 화면(C 그룹)의 「보상 완료」와 맞춘다.
|
||
String get _claimLabel => appL10n.questClaim;
|
||
String get _startLabel => appL10n.questStart;
|
||
String get _doneLabel => appL10n.questRewardDone;
|
||
|
||
/// 홈 「TODAY'S QUEST」 가로 카드 — 사본 `Frame 1984081083`(196×137 · #1A1A1C · r12 ·
|
||
/// 패딩 12 · 간격 28): 제목 16 · 상태 14 #A3A2A0 · 오른쪽 「+보상」 20 #2AC28B, 아래 37 버튼
|
||
/// (보상 가능 = 초록 #3EB58A 흰 글자 · 진행 중 = 흰 바탕 검정 글자). 누르면 퀘스트 목록으로 간다.
|
||
class QuestCard extends StatelessWidget {
|
||
const QuestCard({super.key, required this.quest, this.onTap});
|
||
|
||
final QuestSummary quest;
|
||
final VoidCallback? onTap;
|
||
|
||
static const width = 196.0;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final (label, fill, ink) = switch (quest.state) {
|
||
QuestState.claimable => (
|
||
_claimLabel,
|
||
const Color(0xFF3EB58A),
|
||
AppColors.onCosmic,
|
||
),
|
||
QuestState.inProgress => (_startLabel, AppColors.onCosmic, Colors.black),
|
||
QuestState.done => (
|
||
_doneLabel,
|
||
AppColors.cosmicSurfaceRaised,
|
||
AppColors.onCosmicMuted,
|
||
),
|
||
};
|
||
return Material(
|
||
color: AppColors.cosmicSurfaceContainer,
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpace.s3),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
quest.title,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1.4,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
|
||
if (quest.endsAt case final endsAt?
|
||
when quest.state != QuestState.done)
|
||
TimeAttackCountdown(
|
||
endsAt: endsAt,
|
||
builder: (context, left) => _subtitleText(
|
||
left == null
|
||
? context.l10n.questTimeAttackClosed
|
||
: context.l10n.questTimeAttackLeft(
|
||
formatLeft(left),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
_subtitleText(QuestRow._subtitle(quest)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpace.s2),
|
||
Text(
|
||
_reward(quest),
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1,
|
||
color: Color(0xFF2AC28B),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Container(
|
||
height: 37,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: fill,
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1,
|
||
color: ink,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _subtitleText(String text) => Text(
|
||
text,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1.4,
|
||
color: AppColors.onCosmicVariant,
|
||
),
|
||
);
|
||
|
||
/// 운영값을 못 읽었으면 숫자를 지어내지 않는다.
|
||
String _reward(QuestSummary quest) =>
|
||
quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}';
|