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,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),
],
),
);
}
}