Files
fanuzu/app/lib/features/campaign/widgets/campaign_period_sheet.dart
T

353 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
],
),
);
}
}