QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 5de04ac281
commit 18afc9cb98
25 changed files with 9368 additions and 0 deletions
@@ -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);