353 lines
11 KiB
Dart
353 lines
11 KiB
Dart
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|