QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 캠페인 기여률 카드 — 시안 `272:245738`(상세) · `480:14594`(공유) 공용.
|
||||
///
|
||||
/// #1a1a1c r12 안쪽 12/16. 라벨 12/500 · 「92%」(ABeeZee 32 + % 20) · 10 두께 막대
|
||||
/// (바탕 #000 30%, 채움 그라디언트) · 0 / 목표. 채움 끝 위에 누적 POP 말풍선(100 폭)이 뜬다.
|
||||
class CampaignGauge extends StatelessWidget {
|
||||
const CampaignGauge({super.key, required this.raised, required this.goal});
|
||||
|
||||
final int raised;
|
||||
final int goal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = goal == 0 ? 0.0 : (raised / goal).clamp(0.0, 1.0);
|
||||
final percent = goal == 0 ? 0 : ((raised / goal) * 100).round();
|
||||
final axis = campaignText(
|
||||
12,
|
||||
400,
|
||||
const Color(0xFFCDCDCD),
|
||||
height: 1,
|
||||
spacing: 0,
|
||||
family: AppFonts.display,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final fill = box.maxWidth * ratio;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignGaugeLabel,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 20 / 12,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: '$percent',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '%',
|
||||
style: TextStyle(fontSize: 20, letterSpacing: -0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: campaignText(
|
||||
32,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: fill,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
gradient: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('0', style: axis),
|
||||
Text(formatPop(goal), style: axis),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 31.5,
|
||||
left: (fill - 50).clamp(0.0, box.maxWidth - 100),
|
||||
child: _Bubble(label: formatPop(raised)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 누적 POP 말풍선 — 그라디언트 알약 100×32 + 아래 꼬리.
|
||||
class _Bubble extends StatelessWidget {
|
||||
const _Bubble({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: 100,
|
||||
height: 38,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
color: const Color(0xFF5388CF),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
AppColors.onCosmic,
|
||||
height: 1,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../../core/ui/relative_time.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 목록의 캠페인 카드 — 명세 `523:18399`.
|
||||
///
|
||||
/// *작성일시 타임스탬프 기준 … · 캠페인 카드 업데이트 된 시점의 달성률 표시 ·*
|
||||
/// ***내용은 최대 2줄까지 이후는 … 처리*** *· 반응 수치는 K 단위 활용*
|
||||
///
|
||||
/// 시안(272:241184) — 이미지 342×228 위, 본문 #181818(#1e1e1e 획) 안쪽 14 · 간격 21,
|
||||
/// 작성자 줄 오른쪽 「NN% 달성」 그라디언트 배지, 1px #4f4f4f 구분선, 반응 넷, 흰색 r12
|
||||
/// 「캠페인 보기」.
|
||||
///
|
||||
/// [mine] 은 「내 캠페인」(`370:229665`) 변형 — 작성 시각과 반응 수치 없이 아이콘만 둔다.
|
||||
class CampaignListCard extends StatelessWidget {
|
||||
const CampaignListCard({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.onOpen,
|
||||
this.mine = false,
|
||||
});
|
||||
|
||||
final CampaignCardData card;
|
||||
final VoidCallback onOpen;
|
||||
final bool mine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 캠페인 문서에 대표 이미지 필드가 아직 없어 시안 표본을 쓴다.
|
||||
Image.asset(
|
||||
'assets/figma/campaign-sample.png',
|
||||
height: 228,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181818),
|
||||
border: Border.all(color: const Color(0xFF1E1E1E)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CampaignAuthor(
|
||||
name: card.authorName,
|
||||
createdAt: mine ? null : card.createdAt,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// 사본 「0% 달성」은 같은 그라디언트를 10% 로 깔아 회색에 가깝다.
|
||||
gradient: card.achievedPercent == 0
|
||||
? campaignGradient.scale(0.1)
|
||||
: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.campaignAchieved(
|
||||
'${card.achievedPercent}',
|
||||
),
|
||||
style: campaignText(
|
||||
12,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
card.title,
|
||||
style: campaignText(
|
||||
14,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
card.body,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 1.4,
|
||||
),
|
||||
// 명세 — 내용은 최대 2줄까지, 이후는 … 처리.
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Color(0xFF4F4F4F),
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
Row(
|
||||
children: [
|
||||
_Reaction(AppIcons.heart, mine ? null : card.likes),
|
||||
_Reaction(
|
||||
AppIcons.messageCircle,
|
||||
mine ? null : card.comments,
|
||||
),
|
||||
_Reaction(AppIcons.eye, mine ? null : card.views),
|
||||
_Reaction(AppIcons.share, mine ? null : card.shares),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
Material(
|
||||
color: AppColors.onCosmic,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.campaignView,
|
||||
style: campaignText(
|
||||
14,
|
||||
600,
|
||||
Colors.black,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 작성자 줄 — 단색 원 26 · 닉네임(ABeeZee 12) · 상대 시각(10 #767676). 상세도 같이 쓴다.
|
||||
class CampaignAuthor extends StatelessWidget {
|
||||
const CampaignAuthor({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String name;
|
||||
|
||||
/// null 이면 시각 줄 없이 닉네임만(「내 캠페인」).
|
||||
final DateTime? createdAt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: const BoxDecoration(
|
||||
color: campaignAvatarColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: campaignText(
|
||||
12,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
if (createdAt != null)
|
||||
Text(
|
||||
relativeTime(createdAt!),
|
||||
style: campaignText(
|
||||
10,
|
||||
500,
|
||||
AppColors.onCosmicMuted,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Reaction extends StatelessWidget {
|
||||
const _Reaction(this.icon, this.value);
|
||||
|
||||
final ImageProvider icon;
|
||||
|
||||
/// null 이면 아이콘만.
|
||||
final int? value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ImageIcon(icon, size: 14, color: AppColors.onCosmicMuted),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formatReaction(value!),
|
||||
style: campaignText(12, 500, AppColors.onCosmicMuted, height: 1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 캠페인 진행 기간 시트 — 사본 `333:72497`(시작일만) · `333:72719`(기간 선택 후).
|
||||
///
|
||||
/// 값은 변형 PNG(scale 2) 실측이다(변형 spec.json 미추출): 시트 위 모서리 24 · #101010,
|
||||
/// 날짜 칸 49×49(행 간격 4) · 선택 원 #FF7DDD · 기간 띠 #19191C, 일요일 #F24949 · 이웃 달 #999999,
|
||||
/// 버튼 56 높이(비활성 #333333 · 글자 #777777 / 활성 강조색 · 검정 글자).
|
||||
///
|
||||
/// 규칙은 OS 선택기 때와 같다 — 오늘부터 1년(365일) 안에서만 고른다. 시작 · 끝 순서로 누르고,
|
||||
/// 끝을 시작보다 앞에 누르면 그 날이 새 시작이 된다.
|
||||
Future<DateTimeRange?> showCampaignPeriodSheet(
|
||||
BuildContext context, {
|
||||
DateTimeRange? initial,
|
||||
DateTime? today,
|
||||
}) => showModalBottomSheet<DateTimeRange>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.cosmicSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => CampaignPeriodSheet(
|
||||
initial: initial,
|
||||
today: DateUtils.dateOnly(today ?? DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
class CampaignPeriodSheet extends StatefulWidget {
|
||||
const CampaignPeriodSheet({super.key, this.initial, required this.today});
|
||||
|
||||
final DateTimeRange? initial;
|
||||
final DateTime today;
|
||||
|
||||
@override
|
||||
State<CampaignPeriodSheet> createState() => _CampaignPeriodSheetState();
|
||||
}
|
||||
|
||||
class _CampaignPeriodSheetState extends State<CampaignPeriodSheet> {
|
||||
static const _cell = 49.0;
|
||||
static const _rowGap = 4.0;
|
||||
static const _band = Color(0xFF19191C);
|
||||
static const _sunday = Color(0xFFF24949);
|
||||
static const _outside = Color(0xFF999999);
|
||||
static const _muted = Color(0xFF777777);
|
||||
|
||||
late DateTime? _start = widget.initial?.start;
|
||||
late DateTime? _end = widget.initial?.end;
|
||||
late DateTime _month = DateTime(
|
||||
(_start ?? widget.today).year,
|
||||
(_start ?? widget.today).month,
|
||||
);
|
||||
|
||||
DateTime get _last => widget.today.add(const Duration(days: 365));
|
||||
|
||||
bool _selectable(DateTime d) =>
|
||||
!d.isBefore(widget.today) && !d.isAfter(_last);
|
||||
|
||||
void _pick(DateTime d) => setState(() {
|
||||
if (_start == null || _end != null || d.isBefore(_start!)) {
|
||||
_start = d;
|
||||
_end = null;
|
||||
} else {
|
||||
_end = d;
|
||||
}
|
||||
});
|
||||
|
||||
bool _canMove(int delta) {
|
||||
final m = DateTime(_month.year, _month.month + delta);
|
||||
final first = DateTime(widget.today.year, widget.today.month);
|
||||
final last = DateTime(_last.year, _last.month);
|
||||
return !m.isBefore(first) && !m.isAfter(last);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
final done = _start != null && _end != null;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottom + 13),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 4, 0),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||
color: AppColors.onCosmic,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignPeriodTitle,
|
||||
style: campaignText(
|
||||
20,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
context.l10n.campaignPeriodMax,
|
||||
style: campaignText(16, 400, _muted, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 31),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignYearMonth(
|
||||
'${_month.year}',
|
||||
'${_month.month}',
|
||||
),
|
||||
style: campaignText(16, 400, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
const Spacer(),
|
||||
_MonthNav(
|
||||
onPrev: _canMove(-1)
|
||||
? () => setState(
|
||||
() =>
|
||||
_month = DateTime(_month.year, _month.month - 1),
|
||||
)
|
||||
: null,
|
||||
onNext: _canMove(1)
|
||||
? () => setState(
|
||||
() =>
|
||||
_month = DateTime(_month.year, _month.month + 1),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final w in context.l10n.campaignWeekdays.split(','))
|
||||
Expanded(
|
||||
child: Text(
|
||||
w,
|
||||
textAlign: TextAlign.center,
|
||||
style: campaignText(12, 400, const Color(0xFF767676)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _grid(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: FilledButton(
|
||||
onPressed: done
|
||||
? () => Navigator.of(
|
||||
context,
|
||||
).pop(DateTimeRange(start: _start!, end: _end!))
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: _muted,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: campaignText(16, 600, Colors.black),
|
||||
),
|
||||
child: Text(
|
||||
done
|
||||
? context.l10n.campaignPeriodRange(
|
||||
'${_start!.month}',
|
||||
'${_start!.day}',
|
||||
'${_end!.month}',
|
||||
'${_end!.day}',
|
||||
)
|
||||
: context.l10n.campaignPeriodSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 일요일 시작 주 단위 격자. 앞뒤 이웃 달 날짜로 칸을 채운다.
|
||||
Widget _grid() {
|
||||
final first = DateTime(_month.year, _month.month);
|
||||
final lead = first.weekday % 7;
|
||||
final days = DateUtils.getDaysInMonth(_month.year, _month.month);
|
||||
final weeks = ((lead + days) / 7).ceil();
|
||||
return Column(
|
||||
children: [
|
||||
for (var w = 0; w < weeks; w++) ...[
|
||||
if (w > 0) const SizedBox(height: _rowGap),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 0; i < 7; i++)
|
||||
_day(
|
||||
DateTime(first.year, first.month, 1 - lead + w * 7 + i),
|
||||
i,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _day(DateTime d, int column) {
|
||||
final inMonth = d.month == _month.month;
|
||||
final isStart = _start != null && DateUtils.isSameDay(d, _start);
|
||||
final isEnd = _end != null && DateUtils.isSameDay(d, _end);
|
||||
final inRange =
|
||||
_start != null &&
|
||||
_end != null &&
|
||||
!d.isBefore(_start!) &&
|
||||
!d.isAfter(_end!) &&
|
||||
!DateUtils.isSameDay(_start, _end);
|
||||
// 기간 띠는 시작 · 끝 원의 가운데에서 끊는다.
|
||||
final band = inRange
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(color: isStart ? Colors.transparent : _band),
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: isEnd ? Colors.transparent : _band),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
final selected = isStart || isEnd;
|
||||
final color = selected
|
||||
? AppColors.onCosmic
|
||||
: !inMonth
|
||||
? _outside
|
||||
: column == 0
|
||||
? _sunday
|
||||
: AppColors.onCosmic;
|
||||
final enabled = inMonth && _selectable(d);
|
||||
return Expanded(
|
||||
child: SizedBox(
|
||||
height: _cell,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (band != null) Positioned.fill(child: band),
|
||||
if (selected)
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox.square(dimension: _cell),
|
||||
),
|
||||
Semantics(
|
||||
button: enabled,
|
||||
selected: selected,
|
||||
child: InkResponse(
|
||||
onTap: enabled ? () => _pick(d) : null,
|
||||
radius: _cell / 2,
|
||||
child: SizedBox.square(
|
||||
dimension: _cell,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${d.day}',
|
||||
style: campaignText(12, 400, color, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 월 이동 — 사본 64×28 알약(획 #474747) 안의 ‹ | ›.
|
||||
class _MonthNav extends StatelessWidget {
|
||||
const _MonthNav({this.onPrev, this.onNext});
|
||||
|
||||
final VoidCallback? onPrev;
|
||||
final VoidCallback? onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget arrow(ImageProvider icon, String label, VoidCallback? onTap) =>
|
||||
Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 16,
|
||||
child: SizedBox(
|
||||
width: 31.5,
|
||||
height: 28,
|
||||
child: Center(
|
||||
child: ImageIcon(icon, size: 20, color: AppColors.onCosmic),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF474747)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
arrow(AppIcons.chevronLeft, context.l10n.calendarPrevMonth, onPrev),
|
||||
Container(width: 1, height: 10, color: const Color(0xFF474747)),
|
||||
arrow(AppIcons.chevronRight, context.l10n.calendarNextMonth, onNext),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/relative_time.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 기여 랭킹 조각 — 시안 `333:70971`(사용자 랭킹) · 캠페인 상세 「실시간 랭킹」 공용.
|
||||
|
||||
/// 랭킹 데이터에 사진 필드가 없어 시안 목록의 단색 원을 순위 순서대로 돌려 쓴다.
|
||||
const _avatarColors = [
|
||||
Color(0xFF000000),
|
||||
Color(0xFF7358E9),
|
||||
Color(0xFFFA46E8),
|
||||
Color(0xFFC6D284),
|
||||
Color(0xFFF4CFFA),
|
||||
];
|
||||
|
||||
/// 순위 아바타. 내 줄은 MY 프로필과 같은 시안 표본을 쓴다.
|
||||
class RankAvatar extends StatelessWidget {
|
||||
const RankAvatar({super.key, required this.rank, required this.size});
|
||||
|
||||
final ContributorRank rank;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => rank.isMe
|
||||
? ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/figma/avatar-sample.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// 시안 목록은 4위부터 검정 · 보라 · 분홍 · 올리브 · 연분홍 순이다.
|
||||
color: _avatarColors[(rank.rank + 1) % _avatarColors.length],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 순위 숫자 아래 변동값 — 상승 #f28180 ▲ · 하락 #6866f3 ▼ · 변동 없음 「-」.
|
||||
class RankChange extends StatelessWidget {
|
||||
const RankChange({super.key, required this.change});
|
||||
|
||||
final int change;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (change == 0) {
|
||||
return Text(
|
||||
'-',
|
||||
style: campaignText(10, 500, const Color(0xFF777777), height: 1),
|
||||
);
|
||||
}
|
||||
final up = change > 0;
|
||||
final color = up ? const Color(0xFFF28180) : AppColors.neonViolet;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
up ? '▲' : '▼',
|
||||
style: TextStyle(fontSize: 8, height: 1, color: color),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text('${change.abs()}', style: campaignText(10, 500, color, height: 1)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 순위 한 줄 — 높이 60 · [순위+변동] 28 [아바타 40(원 36) · 6 · 닉네임 14/500 #979b9e] … 기여 POP 16/600.
|
||||
class RankRow extends StatelessWidget {
|
||||
const RankRow({
|
||||
super.key,
|
||||
required this.rank,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 10),
|
||||
});
|
||||
|
||||
final ContributorRank rank;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 17),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${rank.rank}',
|
||||
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
RankChange(change: rank.change),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 28),
|
||||
// 사본 — 40 프레임 가운데 지름 36 원.
|
||||
SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(child: RankAvatar(rank: rank, size: 36)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
rank.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: campaignText(14, 500, const Color(0xFF979B9E), height: 1),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatReaction(rank.pop),
|
||||
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 1~3위 단상 — 2 · 1 · 3 순서, 1위만 20 위로 올린다. 왕관 24 · 아바타 80.
|
||||
class RankPodium extends StatelessWidget {
|
||||
const RankPodium({super.key, required this.top});
|
||||
|
||||
/// 순위 오름차순 앞 세 줄.
|
||||
final List<ContributorRank> top;
|
||||
|
||||
static const _crown = {
|
||||
1: Color(0xFFFEA300),
|
||||
2: Color(0xFFACACAC),
|
||||
3: Color(0xFF9C724A),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final byRank = {for (final r in top) r.rank: r};
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (i, place) in const [2, 1, 3].indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 24),
|
||||
SizedBox(width: 81, child: _place(byRank[place], place)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _place(ContributorRank? r, int place) => r == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(top: place == 1 ? 0 : 20),
|
||||
child: Column(
|
||||
children: [
|
||||
_Crown(color: _crown[place]!, place: place),
|
||||
RankAvatar(rank: r, size: 80),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
r.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatReaction(r.pop),
|
||||
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 체스 킹 왕관(시안 material-symbols:chess-king) — 십자 · 몸통을 그리고 가운데에 순위.
|
||||
class _Crown extends StatelessWidget {
|
||||
const _Crown({required this.color, required this.place});
|
||||
|
||||
final Color color;
|
||||
final int place;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CustomPaint(
|
||||
painter: _CrownPainter(color),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$place',
|
||||
style: campaignText(10, 900, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CrownPainter extends CustomPainter {
|
||||
const _CrownPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final p = Paint()..color = color;
|
||||
final w = size.width;
|
||||
// 십자
|
||||
canvas.drawRect(Rect.fromLTWH(w / 2 - 1, 1, 2, 7), p);
|
||||
canvas.drawRect(Rect.fromLTWH(w / 2 - 3.5, 3, 7, 2), p);
|
||||
// 몸통
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(3, 8, w - 6, 15),
|
||||
const Radius.circular(4),
|
||||
),
|
||||
p,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_CrownPainter old) => old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_gauge.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 공유 대상. 앞의 셋은 OS 공유 시트로, 링크는 문구 복사로 이어진다.
|
||||
enum CampaignShareTarget {
|
||||
instagram('assets/figma/campaign-share-instagram.png'),
|
||||
tiktok('assets/figma/campaign-share-tiktok.png'),
|
||||
x('assets/figma/campaign-share-x.png'),
|
||||
link('assets/figma/campaign-share-link.png');
|
||||
|
||||
const CampaignShareTarget(this.asset);
|
||||
final String asset;
|
||||
|
||||
String get label => switch (this) {
|
||||
instagram => appL10n.shareInstagram,
|
||||
tiktok => appL10n.shareTiktok,
|
||||
x => 'X',
|
||||
link => appL10n.shareCopyLink,
|
||||
};
|
||||
}
|
||||
|
||||
/// 캠페인 공유 오버레이 — 시안 `480:14594`.
|
||||
///
|
||||
/// #101010 r24 카드(좌우 20 여백) 안에 닫기 · 「캠페인 공유」 25/600 · 대표 이미지 303 정사각 ·
|
||||
/// 제목 24/500 · 본문 · 기여률 카드 · 공유 원형 버튼 넷(36 · 간격 16).
|
||||
class CampaignShareDialog extends StatelessWidget {
|
||||
const CampaignShareDialog({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.raised,
|
||||
});
|
||||
|
||||
final CampaignCardData card;
|
||||
final int raised;
|
||||
|
||||
/// 시안은 오버레이 카드가 상태 표시줄 자리까지 올라온다(위 23 · 아래 22). [Dialog] 는
|
||||
/// 안전 영역을 비켜 가므로 [showGeneralDialog] 로 직접 띄운다.
|
||||
static Future<CampaignShareTarget?> show(
|
||||
BuildContext context, {
|
||||
required CampaignCardData card,
|
||||
required int raised,
|
||||
}) => showGeneralDialog<CampaignShareTarget>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.l10n.commonClose,
|
||||
barrierColor: AppColors.cosmicVoid,
|
||||
pageBuilder: (_, _, _) => CampaignShareDialog(card: card, raised: raised),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 23, 20, 22),
|
||||
child: Material(
|
||||
color: const Color(0xFF101010),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkResponse(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
radius: 20,
|
||||
child: Tooltip(
|
||||
message: context.l10n.commonClose,
|
||||
child: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.campaignShareTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: campaignText(
|
||||
25,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Image.asset(
|
||||
'assets/figma/campaign-sample.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
card.title,
|
||||
style: campaignText(24, 500, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
card.body,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CampaignGauge(raised: raised, goal: card.goalPop),
|
||||
const SizedBox(height: 36),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final (i, target)
|
||||
in CampaignShareTarget.values.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: target.label,
|
||||
child: InkResponse(
|
||||
onTap: () => Navigator.of(context).pop(target),
|
||||
radius: 22,
|
||||
child: _icon(target),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _icon(CampaignShareTarget target) => switch (target) {
|
||||
CampaignShareTarget.x => Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(target.asset, width: 21, height: 21),
|
||||
),
|
||||
_ => ClipOval(
|
||||
child: Image.asset(
|
||||
target.asset,
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
|
||||
/// Figma 사본(F5) 글자 — 크기 · 굵기 · 색 · 줄 높이를 spec.json 값 그대로 옮긴다.
|
||||
///
|
||||
/// 자간은 Figma 의 -2.5% 를 기본으로 둔다(글자 크기 비례). ABeeZee 에 한글이 없어
|
||||
/// 한글은 42dot Sans 로 떨어진다 — Figma 렌더와 같은 결과다.
|
||||
TextStyle campaignText(
|
||||
double size,
|
||||
int weight,
|
||||
Color color, {
|
||||
double? height,
|
||||
double spacing = -0.025,
|
||||
String family = AppFonts.body,
|
||||
}) => TextStyle(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: const [AppFonts.body],
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.values[weight ~/ 100 - 1],
|
||||
height: height,
|
||||
letterSpacing: size * spacing,
|
||||
color: color,
|
||||
);
|
||||
|
||||
/// 달성 배지 · 기여 게이지 · 말풍선의 청록→보라 그라디언트(Figma 이미지 채움 실측).
|
||||
const campaignGradient = LinearGradient(
|
||||
colors: [AppColors.cosmicTeal, Color(0xFF5A69DE)],
|
||||
);
|
||||
|
||||
/// 작성자 아바타 자리. 캠페인 문서에 작성자 사진 필드가 없어 시안의 단색 원을 쓴다.
|
||||
const campaignAvatarColor = Color(0xFFED7E7E);
|
||||
Reference in New Issue
Block a user