QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user